Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Function default arguments #91

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

Bottersnike
Copy link

@Bottersnike Bottersnike commented Jan 14, 2025

Rendered

This RFC proposes adding default argument values, removing the need for if arg == nil then arg = default end cases at the start of every function.

TL;DR

function random_point()
    return { x=math.random(), y=math.random() }
end
function print_manhattan_distance(message_prefix = "", point = random_point(), origin = { x=0, y=0 })
    print(message_prefix .. math.abs(point.x - origin.x) + math.abs(point.y - origin.y))
end

along with all of the semantics and type inference you might expect from that example.

I have a working implementation of this feature on my luau fork. The brave adventurer may wish to run a build and play around but otherwise this serves more to show that this could be reasonably add into the language with minimal work required.

@Bottersnike Bottersnike force-pushed the function-default-arguments branch from f2edf59 to 8d84199 Compare January 14, 2025 02:48
@Bottersnike Bottersnike force-pushed the function-default-arguments branch from 8d84199 to a693fcf Compare January 14, 2025 03:01
### Language implementation
Within the AST, it would likely be simpler to add a new `AstArray<AstExpr*> argsDefaults` to `AstExprFunction` alongside `args`, rather than modify `args` to be an `std::pair` due to the existing widespread usage of `args`.

Rather than implement this as a feature of the `CALL` instruction within Luau's VM it is instead suggested by this RFC to implement this as part of the compiler. This both increases compatibility (as the VM remains unchanged) and makes it easier to allow *any* expression to be used.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So does this mean function f(x: number = 3) means that I call f() and it compiles as f(3)?

What happens then if I use that function as a first class value?

What happens then in this case?

local function g(callback: (number) -> ())
    callback()
end

g(f)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ask because surely something with default parameters cannot have a non-optional argument as part of its type and also be a first class value?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggested implementation essentially injects a series of if x == nil then x = default end statements at the start of the function body, so passing f around as a first class value is totally fine. If we run

-- !strict
function f(x: number = 3)
    print(x)
end

local function g(callback: (number) -> ())
    callback()
end

g(f)

we get 3 on stdout as expected.

We do however get a type error of TypeError: Argument count mismatch. Function 'callback' expects 1 argument, but none are specified because the first class value f will have a type of (number?) -> ...any as defined there (to indicate that we need not provide that argument/could provide nil).

Amending g to local function g(callback: (number?) -> ()) that type error goes away.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a section that hopefully formalises that brief explanation into the RFC. lmk if there's still confusion (or another type interaction I've not formalised!).

@stravant
Copy link

What is the behavior of the upvalue references in the default value expressions? Do they act like any other upvalue reference from within the body?

For example:

local x = "foo"
local function getX(arg = x)
    return arg
end
print(getX()) --> foo
x = "bar"
print(getX()) --> foo or bar?

@Bottersnike
Copy link
Author

It would be bar as the arguments are evaluated every time the function is called. Making this not be the case would require either dropping the behaviour where evaluations are performed at call time, or special-casing single upvalues, both of which I don't think would be ideal?

@bjcscat
Copy link

bjcscat commented Feb 16, 2025

If the intent is for this to replace redefining the argument with a default value in the body then it would make sense for it to be captured as an upvalue

@alexmccord
Copy link
Contributor

alexmccord commented Feb 16, 2025

How does this interact with forwarding calls?

function f(x = 0, y = 0) return x + y end
function g(...) return f(...) end
print(g())

@jackdotink
Copy link
Contributor

I don't like default values for function arguments; I think they cause more problems than they solve.

@bjcscat
Copy link

bjcscat commented Feb 18, 2025

I think any questions of the logic are resolved by viewing the default argument as a reassignment.

local function identity(a=1)
    return a
end

would be equivalent to

local function identity(a)
    local a = if a == nil then 1 else a
    return a
end

@Bottersnike
Copy link
Author

As described in the document at the moment, the semantics mean call(nil) would result in the default argument being used instead of the explicitly passed nil. This has the benefit of allowing call(arg1, nil, arg3). There's been quite a bit of discussion about this. On one hand, this matches the existing patterns you'll come across when reading code (where people have implemented default values using if/or/etc. at the start of the function body).

On the other hand, this means passing nil explicitly isn't possible if there's a default argument present. For example we could consider function foo(bar: number? = 1) which no longer has any code path where bar could equal nil. The solution for this would be to disallow call(arg1, nil, arg3), and instead exclusively apply default arguments to trailing omitted arguments.

I'm genuinely unsure which would be better (and maybe JavaScript was cooking with null and undefined :D), so after some thoughts--one might even say comments--regarding this.

@Wunder-Wulfe
Copy link

Wunder-Wulfe commented Feb 23, 2025

I believe that the implementation could benefit from the use of named arguments like in other languages such as python, where

def mix(alpha, low=0, high=1):
    return low + (high - low) * alpha

mix(0.5) # called with default arguments
mix(0.5, -1.0) # default argument for (high) used
mix(0.5, high=20.0) # called with named parameter, low is still default
mix(0.5, high=2.0, low=-2.0) # order of named parameters is irrelevant

mix(0.5, None) # 'None' is used in place of low, as opposed to '0', results in error

# illegal function definition, results in an error as default arguments cannot be followed by normal arguments
def mix(alpha, low=0, high):
    pass

Luckily, Luau does not make use of assignment operators as arithmetic operators, so there is no ambiguity where grammar is unsolvable between func(arg1, arg2=value) and func(arg1, (arg2=value)) (aka arg2=value; func(arg1, arg2))

I am also under the assumption that the default value should be passed as an expression. This would resolve any issues that would arise if developers modify the value, and it would allow for operations to be computed within the default argument expression, such as the following:

local function heapInsert(heap, element, limit=MAX_SIZE-1)
    -- insert item into heap
end

-- later, when developer is adjusting their constants

Heap.MAX_SIZE = 2048;

However, one caveat with default values is that they are not always a good idea depending on the implementation!

local function deep_search(tbl, item, ignore={})
    -- initial check for custom ignore table
    if ignore[tbl] then return nil, nil end;
    -- add to ignore list to prevent cyclic access
    ignore[tbl] = true;
    -- initialize queue
    local queue = {tbl};
    -- initialize temporary variable for current table
    local temp;
    -- while queue is not empty;
    while #queue > 0 do
        -- get table from queue
        temp = table.remove(queue, 1);
        -- iterate through table
        for k, v in temp do
            -- if item has been found, return the key and table it was found in
            if v == item then return k, temp end;
            -- if item is a table and is not in ignore list, add to the ignore list and push into queue
            if type(v) == "table" and not ignore[v] then 
                ignore[v] = true;
                table.insert(queue, v);
            end
        end
    end
    return nil, nil;
end

If the implementation evaluates {} once as opposed to re-using the expression, it will result in undefined behavior and memory leaks, as ignore would function as a static variable, keeping track of the same list if no argument is provided.

This can be observed in the following example:

local key, found_table;

local deep_table = {
    {1, 2};
    {3, 4};
};

deep_table.self = deep_table;

key, found_table = deep_search(deep_table, 3); -- should return '1', and a reference to '{3, 4}'
key, found_table = deep_search(deep_table, 3); -- would return nothing, as 'ignore' would hold a reference to deep_table from the previous iteration

In some languages, this would be the case, so it would greatly depend on the design of the default parameter as to whether or not this would be applicable, however the disadvantage of this system means that you must define constants as locals for performance, as otherwise table constructors and other expressions would result in re-evaluation for every instance of a function call when not overwriting the default argument:

local someConst = table.create(1_000, 0.0);
local function test(arg1, arg2 = someConst)
    -- ...
end

-- calls 'table.create(1_000, 0.0)' whenever test2() is called without 'arg2'
local function test2(arg1, arg2 = table.create(1_000, 0.0))
    -- ...
end

There is also the potential to expose existing parameters to default parameters, but this may increase code complexity significantly

local function some_math(a, b, c=(a*b))
    -- ...
end

-- as opposed to using (high=-1) and computing (#array+high+1)
local function search(array, low=1, high=#array)
    -- body
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

7 participants