Hi all,
[EDIT: I've created a new thread with a significantly-revised pitch.]
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 argument. We would make this change in the next Swift language version where source breaks are permitted (e.g., Swift 6), and (possibly) allow API authors to "opt in" to this model via an attribute in the interim.
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 either annotate specific APIs with the underscored version of the attribute mentioned below (e.g., @_trailingClosureMatching
) or opt in to the new model wholesale with the command-line flag -enable-experimental-trailing-closure-matching
to see the source-compatibility impact.
Unfortunately, the need for both led to an unfortunate compromise: Swift's existing rule for matching a (single) trailing closure, by scanning backward from the end of the parameter list, had to be retained. This makes implementing APIs that work well with trailing closures unnecessarily hard.
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 all of the following are true:
- The parameter is not variadic
- 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 (an TimeInterview
) 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 a small (but significant) number of problems. I'll illustrate one that 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 was designed around the backward-matching rule, such that the trailing closure in the following example is ascribed to content:
. The onDismiss:
argument gets the default argument nil
:
sheet(isPresented: $isPresented) { Text("Hello") }
Prior to multiple trailing closures, onDismiss
would have to be specified within parentheses, and the trailing closure argument remains content:
.
With the forward-scanning rule, the example above becomes a type error because the unlabeled trailing closure matches the onDismiss:
parameter. Note that this API is tuned to backward-matching semantics, and does do by violating the part of the Swift API Design Guidelines that specifies that one should prefer to locate parameters with defaults at the end. The forward-scanning rule better matches a philosophy where parameters come in the following order:
- Non-closure parameters without defaults
- Non-closure parameters with defaults
- Closure parameters without defaults
- Closure parameters with defaults
There's no good corresponding ordering for the backward-scan provided by SE-0279.
Opting in the forward-scanning rule
The forward-scanning rule is simpler and more predictable, but the source compatibility break described above requires the introduction of a new language mode (e.g., Swift 6). I propose adding an attribute @trailingClosureMatching(.forward)
to enable the trailing closure matching rule for a specific API, such that all callers of that API will use the forward-scanning rule. Existing code remains unchanged, so there is no source compatibility break. An API such as animate(withDuration:animations:completion:)
could be introduced with @trailingClosureMatching(.forward)
to get the forward-scanning behavior, while SwiftUI's View.sheet(isPresented:onDismiss:content:)
would retain the backward-scanning behavior it was designed for.
I also propose to explicitly allow APIs to specify the backward-matching rule via @trailingClosureMatching(.backward)
, such that an existing API can explicitly state that it still needs to be used with the backward rule, even after the implementation itself has migrated to the source-breaking release that switches to the forward-scanning rule (e.g., Swift 6). This would only exist for backward compatibility: the expectation is that both forms of @trailingClosureMatching
would be extremely rare.
Note that this approach is in line with the Principles for Trailing Closure Evolution: we maintain source compatibility for Swift < 6 by not changing the rules there, we provide API authors more control of the call site. Forward-looking API authors the opportunity to design new APIs to the forward-scan rule with an attribute, so they can start bending the arc toward the final desired language behavior. By providing an attribute to get the old behavior in new language modes, we enable automatic migration to the new language mode (through the source break) without breaking existing clients that might not want to adopt the new language mode just yet. We do pay a cost in complexity---a new attribute with two modes, and having different rules in current Swift vs. future Swift---but the former is the cost of a smooth transition and the latter is the cost of having the wrong semantics in today's Swift.
Unrelated thing: Disabling the use of the unlabeled trailing closure
[EDIT #2: This is actually unrelated and doesn't need to be here. I'm keeping it here for posterity]
That API's like SwiftUI's View.sheet(isPresented:onDismiss:content:)
don't work well with the forward-scan rule is a problem going forward. The author would prefer that content:
match the unlabeled trailing closure argument (always), requiring onDismiss:
to be specified in the parentheses. For stylistic reasons, Button
's initializer is similar: the label:
should always be the trailing closure argument, with the action:
going in parentheses.
To address this issue, we could add a way to mark a parameter (like onDismiss:
or action:
) as not being a candidate for the unlabeled trailing closure argument. For example, an explicit attribute @noTrailingClosure
:
func sheet(
isPresented: Binding<Bool>,
@noTrailingClosure onDismiss: (() -> Void)? = nil,
content: @escaping () -> Content
) -> some View
[EDIT #1: Switched the example over to UIView.animate(withDuration:animations:completion:)
to match SE-0279]
Doug