Skip to content

Commit a693fcf

Browse files
committed
RFC: Function default arguments
1 parent ff86da0 commit a693fcf

File tree

1 file changed

+200
-0
lines changed

1 file changed

+200
-0
lines changed

docs/function-default-arguments.md

+200
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Function default arguments
2+
3+
## Summary
4+
5+
Add default values to arguments in function definitions, used when a parameter is unspecified or `nil`.
6+
7+
## Motivation
8+
9+
Frequently when writing code, having a default value for an unspecified argument is desired. This can be found in a range of languages, such as C++ and Python. Luau has no first-party support for this, instead filling omitted arguments as `nil` allowing programmers to begin their functions with a series of statements such as:
10+
11+
```lua
12+
arg = arg or default
13+
```
14+
or
15+
```lua
16+
if arg == nil then arg = default end
17+
```
18+
or
19+
```lua
20+
arg = if arg == nil then default else arg
21+
```
22+
23+
While effective this produces a degree of noise at the start of functions. Notably also the `or` short-circuit approach will coalesce all falsey values to the default value, rather than just `nil` values.
24+
25+
The luau type system also does not currently narrow these types correctly. For example,
26+
```lua
27+
function demo_type(arg: number?)
28+
if arg == nil then arg = 0 end
29+
local x = arg
30+
return x
31+
end
32+
```
33+
will result in `x` having an inferred type of `number?` and the function having a return type of `number?`. Resolving this involves creating a new variable name for the parameter post-defaulting rather than shadowing the name. This RFC explicitly hides the optional nature of arguments from the function body.
34+
35+
Further, this defaulting behavior is hidden from tooling that may want to inspect function signatures, such as tooltips in IDEs. A handful of examples from the Roblox standard library that could benefit have been included here:
36+
37+
```lua
38+
function color3.new(x = 0, y = 0, z = 0)
39+
function Vector3.new(x = 0, y = 0, z = 0)
40+
function CFrame.fromEulerAngles(rx: number, ry: number, rz: number, order = Enum.RotationOrder.XYZ)
41+
function DataStoreService:GetDataStore(name: string, scope = "global", options: DataStoreOptions?)
42+
function DataStoreService:ListDataStoresAsync(prefix = "", pageSize = 0, cursor = "")
43+
```
44+
45+
A slightly more complicated Roblox example could call API functions in the default value, for example:
46+
47+
```lua
48+
local Players = game:GetService("Players")
49+
function give_players_item(item, players = Players:GetPlayers())
50+
```
51+
52+
## Design
53+
54+
This proposal at its core suggests the following modification to the luau grammar:
55+
56+
```diff
57+
- parlist = bindinglist [',' '...' [':' GenericTypePack | Type]]
58+
+ parlist = bindinglistwithdefault [',' '...' [':' GenericTypePack | Type]]
59+
60+
(* snip *)
61+
62+
+ bindinglistwithdefault = binding ['=' exp] [',' bindinglistwithdefault]
63+
```
64+
65+
This allows any function argument to be provided with any expression as its default argument. To explain the proposed semantics, the following demonstration function will be used
66+
67+
```lua
68+
local ARG2_CONSTANT = 2
69+
function demo(
70+
arg1: number,
71+
arg2: number = ARG2_CONSTANT,
72+
arg3 = {x=0, y=0},
73+
arg4 = print("arg4 default"),
74+
arg5: number
75+
)
76+
print(arg1, arg2, arg3, arg4, arg5)
77+
end
78+
```
79+
80+
### Semantics: Default expressions are evaluated at call-time
81+
In python, a common foot-gun is defining a function as `def example(arg=[])` which will use a singleton list instance across all calls of the function. This RFC suggests expressions are evaluated strictly when the function is called, matching the semantics of the `if`-based existing methods.
82+
83+
This is shown both by `arg3` creating a new table every call, and `arg4` outputting `"arg4 default"` to the console.
84+
85+
### Semantics: Default expressions are only evaluated when necessary
86+
If a value is provided for an argument, the default expression is not evaluated.
87+
88+
Proving a value for `arg4` will silence the `"arg4 default"` output in the console.
89+
90+
### Semantics: Passing `nil` is equivalent to an unspecified argument
91+
Following similar semantics to assigning a table entry to `nil`, passing `nil` as a function argument causes the default value to be used.
92+
93+
This can be seen by passing `nil` as any of `arg2`, `arg3` or `arg4`.
94+
95+
As a result of this...
96+
97+
### Semantics: Non-default arguments are allowed to follow default arguments
98+
Unlike in other languages, there is no strict requirement that all arguments after the first default argument must also be default arguments. If we wished to call our demonstration function providing a value for only `arg1` and `arg5` we could call it as
99+
100+
```lua
101+
demo(1, nil, nil, nil, 5)
102+
```
103+
104+
This further allows for any arbitrary mixture of provided and non-provided arguments.
105+
106+
### Semantics: Default value expressions cannot access function arguments
107+
All required default arguments are evaluated before function arguments are bound to the local scope.
108+
109+
For example
110+
111+
```lua
112+
function foo(a, b = a)
113+
print(a, b)
114+
end
115+
foo(1)
116+
```
117+
118+
will output `1 nil` rather than the potentially expected `1 1`. When evaluating the default value for `b`, the parameter `a` has not yet entered scope and so `= a` attempts to reference `a` from higher scopes. This behavior is analogous to the statement `local a, b = 1, a`.
119+
120+
It is noted that this may not be desired behavior in all cases; one can imagine a function such as the below where it may be beneficial to be able to access the arguments during defaulting:
121+
122+
```lua
123+
function table.remove(t: Array, pos: number = #t)
124+
```
125+
126+
This RFC does not currently consider this, though future works may re-investigate this behavior.
127+
128+
### Type Semantics: Type annotations take precedence over default types when performing inference
129+
When inferring types, an explicit annotation has precedence over the inferred type of the default argument. For example, in the following function the error should be that a `Type 'string' could not be converted into 'number'`.
130+
131+
```lua
132+
function demo_2(a: number = "demo") end
133+
```
134+
135+
### Type Semantics: Inferred types from default values take precedence over inferred types from the function body
136+
If a type has been inferred from the default value, a conflicting type within the function body will cause an error. The following code is invalid
137+
138+
```lua
139+
function demo_3(a = "demo")
140+
a = 1 -- Invalid
141+
end
142+
```
143+
144+
### Type Semantics: Inferred types from default values are not sealed
145+
When a table type is inferred from a default value, it is not sealed. If the function body makes modifications, these are unified with the inferred type.
146+
147+
In the following example, `point` has a final type of `{x:number,y:number,z:number}`.
148+
149+
```lua
150+
function demo_unsealed(point = {x = 0, y = 0})
151+
point.z = 0 -- Ok
152+
end
153+
```
154+
155+
If sealing is desired, the programmer may be explicit about this using either an annotation or a cast. The former takes precedence over the inferred type entirely, and the latter ensures the inferred type is sealed. Rather than a unique semantic this is a consequence of previously defined semantics.
156+
157+
```lua
158+
type PointType = {
159+
x: number,
160+
y: number,
161+
}
162+
function demo_sealed_1(point: PointType = {x = 0, y = 0})
163+
point.z = 0 -- Invalid
164+
end
165+
function demo_sealed_2(point = {x = 0, y = 0} :: PointType)
166+
point.z = 0 -- Invalid
167+
end
168+
```
169+
170+
### Language implementation
171+
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`.
172+
173+
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.
174+
175+
Each defaulted argument can be implemented using a single inverted `JUMPXEQKNIL` instruction followed by a `compileExpr` call. This costs no additional registers beyond what would be already necessary for evaluation of the expression.
176+
177+
## Drawbacks
178+
179+
Programmers could use this to write some particularly hard to follow code. While `arg = default_factory()` is an intended use case for complex expressions within arguments, it would be viable to have immense numbers of side-effects within a single function call. Likewise, we may see code such as
180+
```lua
181+
function demo(
182+
arg1 = (function()
183+
print("Inside nested function")
184+
end)
185+
)
186+
arg1()
187+
end
188+
```
189+
190+
This could also be considered a feature though for default callback arguments.
191+
192+
Furthermore, there is no strict necessity for this feature. It is at its core syntactic sugar for something programmers have already been doing for a long time. This may be instead considered syntactic *noise*.
193+
194+
## Alternatives
195+
196+
For programmers, the three examples outlined in Motivation are already wildly used as viable alternatives with semantics mostly matching that of the proposal in this RFC.
197+
198+
Addition of proper type narrowing to the language would resolve the issue where the traditional patterns for default arguments fail to narrow the type correctly. The author of this RFC strongly advocates for proper type narrowing regardless of this RFC's status.
199+
200+
For tooling that wishes to identify default arguments, it would be viable to inspect the AST of a function to check for the three existing patterns in widespread use. The author is unaware of any tooling that does this.

0 commit comments

Comments
 (0)