Hi all,
This is the second version of my pitch on forward scan matching for trailing closures. I'm re-pitching based on three significant changes:
- I've removed all of the attributes that controlled how trailing closures were matched, based on significant and well-justified push-back against adding such attributes as permanent fixtures in the language.
- We have a lot more data about how much source breakage occurs in practice ("not much"), coupled with a new heuristic that reduces that much further
- I'm taking a more aggressive stance toward source breakage, suggesting that we break some existing Swift code right now so that we get to the forward-scan model quicker.
Introduction
SE-0279 "Multiple Trailing Closures" threaded the needle between getting the syntax we wanted for multiple trailing closures without breaking source compatibility. One aspect of that compromise was to extend (rather than replace) the existing rule for matching a trailing closure to a parameter by scanning backward from the end of the parameter list.
I propose that we replace the backward scan used by the trailing closure matching rules with a forward scan, which is simpler and works better for APIs that support trailing closures (whether single or multiple) as well as default arguments. This is a source-breaking change that affects a small amount of code: I propose that we take the first (smallest) part of this source break immediately, to be finished by a slightly larger source break in Swift 6
To get a feel for the new behavior, it really helps to write some code against it. I implemented this proposal in the Swift compiler, and have built both a Linux and a macOS toolchain that support it. Using this toolchain, you can experiment with the new behavior. Please try it out!
The problem
Several folks noted the downsides of the "backward" matching rule. The rule itself is described in the detailed design section of SE-0279 (search for "backward"), but I'll reiterate its effect briefly here. Let's try to declare the UIView animate(withDuration:animation:completion:)
method in the obvious way to make use of SE-0279:
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
SE-0279's backward-matching matches the named trailing closure arguments backward, from the last function parameter, then the unnamed trailing closure argument matches the parameter before that. So given the following example (straight from SE-0279):
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
} completion: { _ in
self.view.removeFromSuperview()
}
The completion:
trailing closure matches the last parameter, and the unnamed trailing closure matches animations:
. The backward rule worked fine here.
However, things fall apart when a single (therefore unnamed) trailing closure is provided to this API:
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
Now, the backward rule (which SE-0279 retained for source-compatibility reasons) matches the unnamed trailing closure to completion:
. The compiler produces an error:
error: missing argument for parameter 'animations' in call
animate(withDuration: 3.1415) {
Note that the "real" UIView API actually has two different methods---animate(withDuration:animation:completion:)
and animate(withDuration:animation:)
---where the latter looks like this:
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void
)
This second overload only has a closure argument, so the backward-matching rule handles the single-trailing-closure case. These overloads exist because these UIView APIs were imported from Objective-C, which does not have default arguments. A new Swift API would not be written this way---except that SE-0279 forces it due to the backward-matching rule.
The forward scanning rule
The idea of the forward-scanning rule is to match trailing closure arguments to parameters in the same forward, left-to-right manner that other arguments are matched to parameters. The unlabeled trailing closure will be matched to the next parameter that is either unlabeled or has a declared type that structurally resembles a function type (defined below). For the simple example, this means the following:
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
}
// equivalent to
UIView.animate(withDuration: 0.3, animations: {
self.view.alpha = 0
})
and
UIView.animate(withDuration: 0.3) {
self.view.alpha = 0
} completion: { _ in
self.view.removeFromSuperview()
}
// equivalent to
UIView.animate(withDuration: 0.3, animation: {
self.view.alpha = 0
}, completion: { _ in
self.view.removeFromSuperview()
})
Note that the unlabeled trailing closure matches animations:
in both cases; specifying additional trailing closures fills out later parameters but cannot shift the unlabeled trailing closure to an earlier parameter.
Note that you can still have the unlabeled trailing closure match a later parameter, by specifying earlier ones:
UIView.animate(withDuration: 0.3, animations: self.doAnimation) { _ in
self.view.removeFromSuperview()
}
// equivalent to
UIView.animate(withDuration: 0.3, animations: self.doAnimation, completion: _ in
self.view.removeFromSuperview()
})
Structural resemblance to a function type
When a function parameter has a default argument, the call site can skip mentioning that parameter entirely, and the default argument will be used instead. The matching of arguments to parameters tends to rely on argument labels to determine when a particular parameter has been skipped. For example:
func nameMatchingExample(x: Int = 1, y: Int = 2, z: Int = 3) { }
nameMatchingExample(x: 5) // equivalent to nameMatchingExample(x: 5, y: 2, z: 3)
nameMatchingExample(y: 4) // equivalent to nameMatchingExample(x: 1, y: 4, z: 3)
nameMatchingExample(x: -1, z: -3) // equivalent to nameMatchingExample(x: -1, y: 2, z: -3)
The unlabeled trailing closure ignores the (otherwise required) argument label, which would prevent the use of argument labels for deciding which parameter should be matched with the unlabeled trailing closure. Let's bring that back to the UIView example by adding a default argument to withDuration:
class func animate(
withDuration duration: TimeInterval = 1.0,
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
Consider a call:
UIView.animate {
self.view.alpha = 0
}
The first parameter is withDuration
, but there is no argument in parentheses. Unlabeled trailing closures ignore the parameter name, so without some additional rule, the unlabeled trailing closure would try to match withDuration:
and this call would be ill-formed.
I propose that the forward-scanning rule skip over any parameters that do not "structurally resemble" a function type. A parameter structurally resembles a function type if both of the following are true:
- The parameter is not
inout
- The adjusted type of the parameter (defined below) is a function type
The adjusted type of the parameter is the parameter's type as declared in the function, looking through any type aliases whenever they appear, and performing two adjustments:
- If the parameter is an
@autoclosure
, use the result type of the parameter's declared (function) type, before performing the second adjustment. - Remove all outer "optional" types.
Following this rule, the withDuration
parameter (a TimeInterval
) does not resemble a function type. However, @escaping () -> Void
does, so the unlabeled trailing closure matches animations
. @autoclosure () -> ((Int) -> Int)
and ((Int) -> Int)?
would also resemble a function type.
Source compatibility impact
The forward-scanning rule is source-breaking. A run over Swift's source compatibility suite with this change enabled in all language modes turned up source compatibility breaks in three projects. The first problem occurs with a SwiftUI API View.sheet(isPresented:onDismiss:content:)
:
func sheet(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, content: @escaping () -> Content) -> some View
Note that onDismiss
and content
both structurally resemble a function type. This API fits well with the backward-matching rule, because the unlabeled trailing closure in the following example is always ascribed to content:
. The onDismiss:
argument gets the default argument nil
:
sheet(isPresented: $isPresented) { Text("Hello") }
With the forward-scanning rule, the unlabeled trailing closure matches the onDismiss:
parameter, and there is no suitable argument for content:
. Therefore, the well-formed code above would be rejected by the proposed rule. However, see below.
Mitigating the source compatibility impact
With the View.sheet(isPresented:onDismiss:content:)
API, the forward scan chooses to match the unlabeled trailing closure with onDismiss:
, even though it is clear from the function signature that (1) onDismiss:
could have used the default argument, and (2) content:
therefore won't have an argument if we do that. We can turn this into an heuristic to accept more existing code, reducing the source breaking impact of the proposal. Specifically,
- the parameter that would match the unlabeled trailing closure
argument has a default argument, and - there are parameters after that parameter that require an argument
(i.e., they are not variadic and do not have a default argument), up until the first parameter whose label matches that of the next trailing closure (if any)
then do not match the unlabeled trailing closure to that parameter. Instead, skip it and examine the next parameter to see if that should be matched against the unlabeled trailing closure. For the View.sheet(isPresented:onDismiss:content:)
API, this means that onDismiss
, which has a default argument, will be skipped in the forward match so that the unlabeled trailing closure will match content:
, maintaining the existing Swift behavior.
This heuristic is remarkably effective: in addition to fixing 2 of the 3 failures from the Swift source compatibility suite (the remaining failure will be discussed below), it resolved 17 of the 19 failures we observed in a separate (larger) testsuite comprising a couple of million lines of Swift.
I propose that this heuristic be part of the semantic model for forward scan for now, then be removed in Swift 6 so that only the simpler, more predictable forward scan persists in the language. This approach should bring the immediate source breakage down to an acceptable level for an incremental language update, while leaving the greater (although still manageable) source breakage for the next major language version.
Remaining source compatibility impact
The remaining source compatibility failure in the Swift source compatibility suite, a project called ModelAssistant, is due to this API:
init(startHandler: ((AOperation) -> Void)? = nil, produceHandler: ((AOperation, Foundation.Operation) -> Void)? = nil, finishHandler: ((AOperation, [NSError]) -> Void)? = nil) {
self.startHandler = startHandler
self.produceHandler = produceHandler
self.finishHandler = finishHandler
}
Note that this API takes three closure parameters. The (existing) backward scan will match finishHandler:
, while the forward scan will match startHandler:
. The heuristic described in the previous section does not apply, because all of the closure parameters have default arguments. Existing code that uses trailing closures with this API will break.
Note that this API interacts poorly with SE-0279 multiple trailing closures, because the unlabeled trailing closure "moves" backwards:
// SE-0279 backward scan behavior
BlockObserver { (operation, errors) in
print("finishHandler!")
}
// label finishHandler, unlabeled moves "back" to produceHandler
BlockObserver { (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// label produceHandler, unlabeled moves "back" to startHandler
BlockObserver { aOperation in
print("startHandler!") {
} produceHandler: (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
The forward scan provides a consistent unlabeled trailing closure anchor, and later (labeled) trailing closures can be tacked on:
// Proposed forward scan
BlockObserver { aOperation in
print("startHandler!") {
}
// add another
BlockObserver { aOperation in
print("startHandler!") {
} produceHandler: (aOperation, foundationOperation) in
print("produceHandler!")
}
// specify everything
BlockObserver { aOperation in
print("startHandler!") {
} produceHandler: (aOperation, foundationOperation) in
print("produceHandler!")
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
// skip the middle one!
BlockObserver { aOperation in
print("startHandler!") {
} finishHandler: { (operation, errors) in
print("finishHandler!")
}
I contend that we cannot get to a usable model for SE-0279 multiple trailing closures without breaking source compatibility with this API.
Workaround via overload sets
APIs like the above that will be broken by the forward scan can be "fixed" by removing the default arguments, then adding additional overloads to create the same effect. For example, drop the default argument of finishHandler
so that the heuristic will kick in to fix calls with a single unlabeled trailing closure:
init(startHandler: ((AOperation) -> Void)? = nil, produceHandler: ((AOperation, Foundation.Operation) -> Void)? = nil, finishHandler: ((AOperation, [NSError]) -> Void)) {
self.startHandler = startHandler
self.produceHandler = produceHandler
self.finishHandler = finishHandler
}
One can then add overloads to handle other cases, e.g., the zero-argument case:
init() {
self.init(startHandler: nil, produceHandler: nil, finishHandler:nil)
}
For those counting, one of the other unaccounted-for failures was due to this issue as it manifest's in SwiftUI's TextField initializer:
extension TextField where Label == SwiftUI.Text {
public init(_ titleKey: SwiftUI.LocalizedStringKey, text: SwiftUI.Binding<Swift.String>, onEditingChanged: @escaping (Swift.Bool) -> Swift.Void = { _ in }, onCommit: @escaping () -> Swift.Void = {})
}
One last bug...
The last known source-compatibility failure is due to a bug in the existing backward scan, which---in certain rare circumstances---actually allows one to get arguments passed out-of-order:
struct X {
let content: () -> Int
var optionalInt: Int?
}
// This...
_ = X(optionalInt: 17) { 42 }
// is currently accepted as being equivalent to
_ = X(content: { 42 }, optionalInt: 17)
This code never should have been accepted in the first place, but it breaks under the implementation of the forward scan.
[EDIT #1: Extended the heuristic to account for multiple trailing closures, based on feedback from @Jumhyn.]
Doug