I'd like to spin off the thing mentioned briefly in another thread as a pre-pitch.
Pitch
Introduce an explicit marker to denote autoclosure parameters making them prominent.
Motivation
bar(foo(42))
Question: is foo(42) being actually called here or not? If called - how many times?
As of now - we don't know the answer and that's a problem. We need to go see the foo
's declaration and check if its parameter is a normal parameter or an autoclosure parameter:
bar(_ v: Int) { ... }
baz(_ v: @autoclosure () -> Int) { ... }
If it's normal (this would be in the majority of cases) the bar is definitely called and called just once.
If it's an autoclosure – it could be called zero, one or more times – to know for sure we must dive deeper and either look at foo
's documentation or its implementation.
Nothing at the call site hints us that bar
could be not called at all (or called more than once), and given that in the vast majority of cases parameters are not "autoclosed" it is very easy to miss the rare occasions when they are. That we are not hinted and alert at the call site about this possibility is not ideal.
Proposal
Use the existing precedent established for inout
parameters:
someFunc(&value) // 🧐 our attention is called
otherFunc(value) // business as usual
which makes it clear at the call site how parameters are passed use some explicit marker at the call site to denote the autoclosure case. Bike-shedding:
baz(foo(42)) // 🛑 compilation error or warning
baz(autoclosure foo(42)) // 🧐 our attention is called
bar(foo(42)) // business as usual
There's a well known precedent that in the Bool expressions:
apples && oranges
apples || oranges
the second parameter is evaluated lazily. We could require the marker there as well:
apples && autoclosure oranges // 🤔
Although this feels too harsh and too against the established tradition of short-circuiting bool operators. We could make an exceptions for (all) operators to not require the marker.
Compatibility
This is a breaking change. To ease the transition we could use a warning instead of an error initially, then deprecate the old way, and in some future version make the old way a compiler error. As an interim step we may expose a compiler option that would toggle between "old way is ok" / "warning for old way" / "error for old way".
Alternatives considered
-
Don't make the change. The problem with this is broken principle of least surprise. Swift is designed to make code easy to read and the absence of explicit marker at the call site doesn't help with that.
-
Prohibit
autoclosure
parameters altogether, writing the closures explicitly. This deemed too harsh of a change. Avoiding extra nesting is why autoclosure parameters were invented to begin with. -
Prohibit
autoclosure
parameters in functions but leave it for operators. This seems to be harsh as well. The nesting is increased which is not good. While the last autoclosure parameter can be changed to a not so bad trailing closure:
// current:
baz(foo(42))
// could be:
baz {
foo(42)
}
it's not so rosy for parameters which are not last:
// current:
baz(foo(42), param2)
// could be:
baz({foo(42)}, param2) // 😑
-
Don't make an exception for operators (i.e. require the marker for them as well). This feels too harsh and against what we love about bool operators.
-
Use
@lazy
instead of@autoclosure
marker. Perhaps this makes sense if@autoclosure
designation is also renamed to@lazy
otherwise it would be inconsistent. -
Improve the
@autoclosure
declaration:
- remove @
- remove the
() ->
part // there can't be anything else! - don't require parens to evaluate parameter
- in rare cases when parameter should be passed further as an autoclosure use the marker.
// current:
baz(_ param: @autoclosure () -> Int) {
qux(param) // passing param further down as an autoclosure
quux(param()) // param is evaluated
let x = param() // ditto
}
// current usage:
baz(autoclosure foo(42))
// proposed:
baz(_ param: autoclosure Int) {
qux(autoclosure param) // passing param further down as an autoclosure
quux(param) // param is evaluated, as if it was written as param()
let x = param // ditto
}
// proposed usage
baz(autoclosure foo(42))
- combine 5 and 6:
// proposed:
baz(_ param: lazy Int) {
qux(lazy param) // passing param further down as an autoclosure
quux(param) // param is evaluated, as if it was written as param()
let x = param // ditto
}
// proposed usage
baz(lazy foo(42))
- Use some non-word marker. After all with
inout
parameters we do not use the "inout
" keyword itself:
someFunc(inout value) // ❌
The choice of the marker is open though. Could it be "&" or would that feel completely wrong?
// reuse "&" symbol? 🤔
baz(&foo(42))
// invent something new altogether? 🤔
baz(^foo(42))