One aspect of language design can be generalized to the concept of block evaluation, which we can basically define as evaluating an expression by executing a block of statements until they return the result.
Using a block of code as an expression (typically via some kind of a function expression or closure sugar) can be useful in a bunch of situations. This is most notable in languages like Python, which lacks any mechanism for using statements in an expression (due to its reliance on significant indentation to delimit blocks).
Swift has trailing closures, which are an especially elegant solution, where applicable. However, they are not expressions in their own right (they must be preceded by an expression that accepts the closure), so they cannot (directly) solve the problem that the Multi-statement if/switch/do Expressions pitch is trying to solve.
However, we can completely remove the limitation on closures, and allow a block of statements to form an expression anywhere that an expression is allowed, without making any changes to the language whatsoever. Instead, we can simply introduce a one-line, helper function that takes a closure, invokes it with no args, then returns the result:
func evaluate<T>(_ closure: () -> T) -> T { closure() }
Note: We’re ignoring attributes like @discardableResult
and @inlinable
et cetera, just for the sake of simplicity.
Block evaluation inside any expression (or sub-expression) removes the problem that the pitch mentioned above attempts to solve. Multiple statements would be valid inside any expression. So, we would have no need for this new grammar:
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default:
log("this is unexpected, investigate this")
then 4
}
We can just use this (already legal) grammar:
let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
case 0x0800..<0x1_0000: 3
default: evaluate {
log("this is unexpected, investigate this")
return 4
}
}
The use of return
here is not an issue, as in practice, we either want to return 4
from the containing function or make the switch-expression evaluate to 4
, but not both in the same context (returning from a function precludes evaluating to anything).
Note: It is still possible (using evaluate
) to return or evaluate, based on a predicate, within the default
clause above, but using switch
as an expression is syntactic sugar, so while evaluate
can handle every corner case, there’s no real need to demonstrate that.
The evaluate
function also removes the need for unsightly trailing parenthesis after the closing brace of immediately invoked closures. For example, the following code follows the current idiom:
var player: AVAudioPlayer = {
let path = Bundle.main.path(forResource: "sound1", ofType: "mp3")!
let url = NSURL(fileURLWithPath: path)
let player = try! AVAudioPlayer(contentsOf: url as URL)
player.prepareToPlay()
return player
}()
With evaluate
, we would just do this instead:
var player: AVAudioPlayer = evaluate {
let path = Bundle.main.path(forResource: "sound1", ofType: "mp3")!
let url = NSURL(fileURLWithPath: path)
let player = try! AVAudioPlayer(contentsOf: url as URL)
player.prepareToPlay()
return player
}
That small change substantially improves readability by indicating to the programmer that the closure is immediately invoked (which is important context) before they read through its entire (possibly lengthy) body.
Note: Swift depends on immediately invoked closures for initializing default values inside type definitions (which cannot contain arbitrary setup code). You cannot opt out of using them.
Having a nice grammar for immediately invoked closures also promotes the adoption of other idioms that are less attractive otherwise. For example, we can use scope intercession to insert a scope between a function and its containing scope. Take the following function as an example of how this is useful:
func f(x: Int) -> Int {
let y = expensiveComputation()
return x + y
}
Given that y
is expensive to compute, it’s worth considering whether it changes from one invocation to the next. If not, we’d be tempted to move y
outside the function, so it only gets computed once:
let y = expensiveComputation()
func f(x: Int) -> Int { x + y }
That would be more efficient, but now y
has absconded its proper scope. At a minimum, we’ll need to document why it’s hanging around outside of f
, and probably rename it as well (even though the shorter, original name was fine, when it was confined to its proper scope).
An immediately invoked closure can easily resolve this issue by inserting an extra scope between the function and its containing scope:
let f: (Int) -> Int = evaluate {
let y = expensiveComputation()
return { x in x + y }
}
There are other, similar ways that evaluate
can be useful. Still, the ability to use a block as an expression anywhere that an expression is allowed is the main advantage (especially now that certain statements can be used as expressions).
So far, the examples have all used a closure that takes no arguments, as passing arguments when immediately invoking a closure is uncommon. Still, it does happen, and is something that should be supported (and something I was asked about, when discussing this topic in another thread). Overloading evaluate
to take a single argument (and pass it along to the closure) is simple enough:
func evaluate<A, R>(_ a: A, closure: (A) -> R) -> R { closure(a) }
let x = evaluate(1_000) { x in x + x } // 2000
let y = evaluate("foo") { x in x + x } // "foofoo"
Note that the arguments also precede the closure now (instead of dangling off the end).
This approach can be extended to an arbitrary number of arguments by hardcoding overloads for each number of arguments. For example, this version takes two arguments:
func evaluate<A, B, R>(_ a: A, _ b: B, closure: (A, B) -> R) -> R { closure(a, b) }
let x = evaluate(1, 2) { x, y in x + y } // 3
If I understand correctly, these kinds of hardcoded variadics are exactly the problem that packs solve, so evaluate
could be generalized to any number of arguments that way. In any case, passing more than one argument to an immediately invoked closure is rare, so a small number of overloads would suffice, even without packs.
Obviously, the name evaluate
is open to debate as well. Personally, I’ve always named these kinds of functions iife
(Immediately Invoked Function Expression), but that didn’t seem right for Swift (which consistently uses the term closure instead of function expression). In any case, in practice, the invocation will very often (but not always) be the rvalue in an assignment (as above), so any name should work especially well in that context.
Thanks for taking the time.