[Pitch #1] Forward scan matching for trailing closures (source breaking)

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

20 Likes

This is a great step to bringing us closer to the desired trailing closure API. All in all, I agree that it’s best to add an attribute for now to ease the transition to the new language version, while also offering the much-wanted feature now. I’m confused, however, about the @noTrailingClosure attribute. In both cases you describe, the problem could be solved by the aforementioned @trailingClosureMatching(.backward). Could you explain a case where the use-case would be substantial enough to prompt the creation of a new attribute?

I'm having a hard time with this pitch. For a couple reasons I think it makes a stronger argument for backward matching than it does for forward matching.

  1. The only real world example given is better with backward matching. The new attributes required to prevent regressions will make code harder to read and write.
  2. To me, "The problem" section is confusing not because of the matching direction but because the example with a single trailing closure doesn't have an argument label.
1 Like

IIRC, trailing closures can today match a single “slot” in a variadic parameter. Could you elaborate on this restriction given that it results in changing more than just the direction of the matching rule?

Is this pseudo-enum syntax precedented for attributes? The examples I can think of (e.g., @inline(never)) don’t use the leading dot. Is there an expanded form (like, say, @trailingClosureMatching(Direction.forward)) that justifies the leading dot?

Is there a migration story for clients of libraries that haven’t yet added the appropriate @trailingClosureMatching(...) attribute? E.g., say we have:

// Module A
func foo(_: (() -> Void)? = nil, _: (() -> Void)? = nil) {}

// Module B
import A
foo { print(“hello”) }

If module A is outside the control of the author of module B, what does the upgrade process look like for the author of module B look like if they want to adopt the new language version?

Overall, I have to agree with @cosmer. The fact that the pitch seems to introduce a brand new problem that has to be solved by yet another attribute seems like a significant drawback. Seeing more examples of real-world APIs that suffer due to the current rules and how these examples would be improved would make this a stronger pitch.

1 Like

I’m glad to see that breaking changes are now on the table to fix the situation with trailing closures. That said, I wish we would have taken more time before accepting SE-0279 so we didn’t have to pitch a breaking change to it so soon afterwards. IMO, that is an indication that its acceptance was indeed rushed . Hopefully a lesson can be learned from this experience. And if at all possible, I think it would be good to try and fix this before Swift 5.3 ships and leaves us with code that depends on the subtle rules of SE-0279.

I agree that the forward scanning rule is much simpler to understand. I’m very uncomfortable with maintain both forward and backward scanning indefinitely. I think having both rules would be confusing for many programmers. I would not be surprised to find myself confused by this design from time to time.

I continue to think the best solution is to treat trailing closure labels like any other argument labels. As with this pitch, an opt-in attribute would be used during a migration period.

I don’t think we should disable trailing closures on an argument by argument basis. The desire to do this is an artifact of the current suboptimal design that disallows labels for the first trailing closure. It also feels like it seeks to impose style guidelines on the community in the context of SwiftUI code. Specifically, this syntax works with very short, single statement action closures like this:

sheet(isPresented: $flag, onDismiss: { doSomething() } ) {
   // content
}

It does not works so well with longer action closures:

sheet(
    isPresented: $flag, 
    onDismiss: { 
         // several lines of code
    } 
) {
   // content
}

Even if it a bad idea to include long action closures inline, people are going to do it. Hopefully in the current language design they have the sense to just avoid trailing closures altogether for the call:

sheet(
    isPresented: $flag, 
    onDismiss: { 
         // several lines of code
    },
    content: {
         // the content
    }
)

If we support a label on the first trailing closure then the API “just works” with multiple trailing closures:

sheet(isPresented: $flag)
    onDismiss: { 
         // several lines of code
    }
    content: {
         // the content
    }

Astute readers will notice that the content label here is required, and if trailing closure works like all other argument labels it would also be required in the opening example which is preferable in SwiftUI when the action closure is short and the content is long:

sheet(isPresented: $flag, onDismiss: { doSomething() } ) 
   content: {
       // content
   }

This is clearly undesirable in a XML-ish DSL like this. I think the way to model the desired syntax accurately in the language is to provide an attribute on the closure argument that is intended to be described by the base name (and only that argument - this attribute would only support one argument in a declaration):

func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil, 
  @elidedWhenFirstTrailingClousre content: @escaping () -> Content
) -> some View

With this attribute in hand, we can now express the desired API while also allowing multiple trailing closures to work the way most people seem to want them to work.

This attribute also helps facilitate migration. We could also introduce a @modernTrailingClosures attribute which opts-in to the new trailing closure behavior:

@modernTrailingClosures
func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil, 
  @elidedWhenFirstTrailingClosure content: @escaping () -> Content
) -> some View

In Swift 6, when breaking changes are supported, @modernTrailingClosures would become the default and the attribute itself could be deprecated. We could migrate existing single trailing closure methods by automatically applying the @elidedWhenFirstTrailingClosure attribute to the trailing closure argument.

This would support all single closure call sites as-is (whether trailing or not). Hopefully limiting single trailing closure churn to declarations would make the code churn more palatable than a solution that would require migrating usage sites.

This is my latest thinking on the topic of trailing closures. It turned out to be a lot longer than intended when I started writing it, but hopefully it is useful to the discussion.

18 Likes

I'm in strong agreement with @anandabits here. This proposal only seems to complicate the rules around trailing closures, and would require programmers to memorize whether individual APIs provide "forward" or "backward" matching rules in order to effectively use them.

I agree—so many of the issues around multiple trailing closure syntax (and trailing closure syntax in general) seem to arise from the fact that it is a weird corner of the language where the existing rules for parameter labels are thrown out the window. If we're comfortable with a source change in a new language version, I believe that this solution most effectively satisfies the Control of the Call Site and Language Consistency and Future Directions principles that Ben outlined in Principles for Trailing Closure Evolution Proposals.

Rather than introduce an additional attribute to support further special-casing of the rules around trailing closure labels, I would instead expect the API to be changed to read as:

func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil, 
  _ content: @escaping () -> Content
) -> some View

The call site examples you've written would then read as:

sheet(isPresented: $flag)
    onDismiss: { 
         // several lines of code
    }
    _: {
         // the content
    }

and (same as today):

sheet(isPresented: $flag, onDismiss: { doSomething() }) {
    // content
}

IMO, this leaves the language in the best place consistency-wise, and results in very predictable rules for programmers to follow at the call site.

5 Likes

I think the above example is far less clear without a label than it would be with a label.

Beyond that, a significant advantage of @elidedWhenFirstTrailingClosure is that it dramatically reduces the code churn in moving to the new model. Instead of fixing usage sites of single trailing closure code, we only need to apply an attribute to declarations. This could be done automatically by the migratory. Even better, maybe the compiler could learn to apply this attribute automatically when importing modules compiled with older versions of Swift.

So I think this attribute has significant advantages in migration and has meaningful advantages as a permanent part of the language design. It does make the model slightly more complicated. But only slightly, and I think that complexity pays for itself well.

1 Like

The change you propose breaks any API that has an argument label and is used with a trailing closure. There are a number of cases in the standard library like this already, so the scale of the source break is far larger than what I’m proposing.

A language mode that allows us to introduce breaking changes does so in small ways. The core team has communicated that it’s not a license to make large-scale changes. I don’t consider your preferred solution to be in scope, which is why I’m seeking alternatives.

Doug

So just don't apply the multiclosure rules to the single closure case? As people have proposed before, separating the multi closure and single closure cases would allow these two syntaxes to live together, to the benefit of both. Multiclosure APIs gain clarity and self consistency while the single closure remains compatible with prior use. In fact, adding an attribute to require the label of a single trailing closure seems like a far better use case than using it for the multiclosure case, as it provides a consistent compatibility path between the two syntaxes. If your goal is to simplify parsing and developer mental models, this seems to be the easiest path.

10 Likes

So I believe the issue we are having is that the main unlabeled trailing closure is implicitly selected so I suggest that we make this selection explicit then we don’t need to change the scanning rules.

func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil, 
 @mainTrailingClousure  content: @escaping () -> Content
) -> some View

Something like @mainTrailingClosure will only allow that closure to be the main closure. If there are any other closures after the main then those will use the multi closure labels. Any closure or auto closures before the tagged main trailing closure can not be used as trading clausures as the main is the only one that can be without a label.

The original SE-0279 tried to separate the single and multi-closure cases syntactically at the call site, and avoided all of these problems, so in a sense I agree with you. But the strongest motivator for the SE-0279 syntax we got was making the multi-closure case a natural extension of the single-closure case. It achieved that syntactically; I don’t think that’s up for renegotiation.

Doug

This is true; I’ve recently written some code that takes advantage of this rather nicely because the common case is a single closure which ends up looking much better not nested within parens at the call site but yet the semantics of the function are such that a variadic list of closures makes perfect sense and indeed plays especially nicely if passed by name rather than anonymously.

In short, I’m also curious why we need to break that use case with forward scanning.

While this is true, the scope of the breakage is limited to applying the @elidedWhenFirstTrailingClosure attribute to the affected declarations during migration. This breakage wouldn’t occur until Swift 6 when people have already had plenty of opportunity to migrate their code manually using @modernTrailingClosures if desired. Usage sites would never be impacted unless developers choose to refine their API contracts.

The @elidedWhenFirstTrailingClosure attribute could be applied to all relevant standard library APIs. This would eliminate any breakage of usage sites of standard library APIs. If the compiler is able to determine whether a 3rd party library has been updated for Swift 6 or not (perhaps via the swiftmodule file and / or Swift package tools version) users of third party libraries would also not be affected unless the library author chose not to have the migrator automatically apply this attribute.

The approach I described here is a new one that has not yet been discussed and significantly reduces the source breakage relative to a model that impacts usage sites. I think it is worthy of consideration as it would leave the language with a much more consistent and cohesive design.

Is having a migrator automatically apply an attribute to labeled, single closure argument function declarations really so problematic as to motivate living with a suboptimal design for trailing closures indefinitely?

6 Likes

@trailingClosureMatching is meant to be a transitional thing as we move from Swift 5 to Swift 6. I expect it to be very rare, and only used begrudgingly when a particular API's clients cannot be migrated quickly.

I'm not strongly attached to @noTrailingClosure; it serves some APIs like the ones mentioned above, but it could easily be dropped from this and considered later, if it really mattered.

Doug

Yes, today that can match a single "slot" in a variadic parameter, but the scheme doesn't generalize to more than one argument. We don't technically need this restriction.

This kind of thing has been discussed as part of static custom attributes. I'd really rather not discuss this spelling of this transitional attribute at this point; the semantics matter so much more.

The migration story is that the client always follows the rules that the library was compiled with. If your "module A" is compiled in Swift 5, all clients of module A follow the Swift 5 (backward) rules. If your "module A" is compiled in Swift 6, all clients of module A follow the Swift 6 (forward) rules. The attribute lets you override the default rule of your language, e.g., in case you migrate the implementation of the library from Swift 5 to Swift 6 (so you don't break your clients).

@noTrailingClosure doesn't need to be here; it's a separable issue. I thought people understood the depth of this problem already, so I got lazy with the examples. I've now reworked the pitch (in the first post) to show that the central example of SE-0279 doesn't work due to the backward-matching rule.

Doug

3 Likes

Whatever we change, we will be living with both the "Swift 5 rule" and the "Swift 6 rule" for a very long time. In your proposal, it's "the unlabeled trailing closure can match a labeled argument" vs "it cannot match a labeled argument." I assert that it's important to make very limited changes that affect as little code as possible, so most Swift 5 code acts identically to Swift 6 code.

I agree; I'm removing this from my pitch because it's control we don't need, and argued that the sheet(isPresented:onDismiss:content:) example is not in line with the API design guidelines.

After looking at this, I realized that the forward scan already provides a way to strongly discourage you from specifying the last parameter, by leaving it unlabeled:

func sheet(
  isPresented: Binding<Bool>,
  onDismiss: (() -> Void)? = nil, 
  _ content: @escaping () -> Content
) -> some View

You could write:

sheet(isPresented: $flag, onDismiss: { doSomething() } ) 
   _: {
       // content
   }

but, well, that _: is a strong indicator that you're working against the API author. And it's a simpler solution than either of the attributes you or I proposed for this specific issue. And as I note in my updated pitch, this API should probably be:

func sheet(
  isPresented: Binding<Bool>,
  content: @escaping () -> Content
  onDismiss: (() -> Void)? = nil, 
) -> some View

such that defaulted parameters go toward the end, something that is supported with forward scanning.

This implies a forward scan, because that's how argument labels are matched. So I don't think we're actually debating the forward scan per se, but whether trailing closures must have argument labels matching that of the parameter call site in Swift 6 (your proposal) or we update the current scheme of ignoring the argument label by skipping over non-closure arguments (my proposal).

It's less about the migrator, and more about whether most Swift 5 code and most Swift 6 code work roughly the same. With my proposal, the changes affect APIs with multiple closure parameters and defaults in non-standard places (i.e., before the end). With your proposal, even a simple declaration lifted from the standard library:

func sorted(by predicate: (Element, Element) -> Bool) -> [Element] { ... }

with either need some kind of historical-accident attribute (@elidedWhenFirstTrailingClosure) or will have a different call signature in Swift 5 (sorted { $0 > $1 }) and Swift 6 (sorted by: { $0 > $1 }). That invalidates a lot of code that exists today, including code in books and tutorials where one cannot automatically apply a migrator.

The "Source Stability" section of the Principles for Trailing Closure Evolution Proposals thread contains this example:

subscript currently deviates from regular function declaration, in that parameter names are not argument labels. Instead you must use explicit argument labels. This is confusing, and might be better made consistent, but would cause extensive source breakage. While this could be language versioned, it would cause considerable source churn and block adoption of other Swift features in the same version. The consistency benefits are therefore clearly outweighed by the cost.

I consider your proposed design to be in a situation nearly identical to what the Core Team has described above: Yes, having a single set of matching rules would be more consistent that having the unlabeled trailing closure argument behave differently. Yes, the migration could be automated with the attribute. But the scope of the change you're proposing is larger (both conceptually and in the volume of changed code) than the subscript one above, and the Core Team preemptively rejected that by using it as an example.

(Full disclosure: my two hats are stacked lopsidedly on my head right now. I brought the subscript example up to the Core Team because I felt like it was good for establishing precedent (either way!) about how much source code churn could be considered in a new language version. Now I'm wielding that precedent to support my own proposal against an alternative.)

Doug

1 Like

This may be where we disagree. IMO it’s important to balance this with the language model we are left with after the breaking change is complete. If it’s worth making a breaking change, perhaps it’s worth making a slightly broader one if it leaves us with a clearly superior model in the end.

This is obviously not my call and it seems as if the core team views it differently. I was hopeful that perhaps this new approach which limits source breakage to declarations may have been more palatable, but it sounds like not. :man_shrugging:t2:

This looks wrong to me. You’re not proposing to allow labels for the first trailing closure are you? That is what you wrote here. I suspect maybe you intended to write both closures as trailing like this:


sheet(isPresented: $flag) 
   onDismiss: { doSomething() } 
   _: {
       // content
   }

Either way, I don’t think it would be much better if we have a language model where the syntax works whether trailing closure syntax is used or not. With this approach, API authors only need to assign labels (and perhaps consider whether the meaning of a closure is implied by the base name as in the new attribute I proposed). Use of trailing closures would remain a stylistic choice.

This runs against the grain of SwiftUI’s API style as I understand it. As far as I can tell, the last trailing closure always contaIns the child content. This provides a hierarchical markup-like syntax. Putting the action closure before the content is not an arbitrary choice.

No we aren’t - one of the first things I said was that I agree that forward scan is a simpler rule to understand. I don’t like the idea that we would have an attribute supporting backward scan in the long run though.

I don’t think this attribute would be a historical accident. After having thought about this topic long enough, I now view it as a reasonable attribute for a language model that wants to elide labels in some cases. I think eliding the first trailing closure label implicitly is probably a mistake in retrospect. But I also think elision in this setting while retaining the label when the closure is not trailing makes sense in some cases, at least in the multiple trailing closure case.

One way to reduce source breakage under the model I proposed is to retain elision when there is a single closure at the end of the argument list while requiring the attribute to opt-in to elision when there are multiple closures at the end of the argument list. I think this approach would meet the source stability requirements relative to Swift 5.2. (I haven’t thought about what differences there would be relative to Swift 5.3 as it currently stands.)

This approach would allow us to switch to forward scan without needing an attribute to opt-in to backward scan. Instead, the API author would specify which label may be elided when in the first position. Unless we add support for labeling the first trailing closure, this label would also happen to prevent the user from writing earlier labeled closures in trailing position.

I would like to have the ability to write labels for trailing closures in the first position, but this is separable from the issue of supporting APIs designed for backward scan under a change to a forward scan model. I think explicitly specifying which arguments may have their labels elided is better than overriding scan behavior. The former is specified in terms of the programming model and the latter in terms of the implementation model.

—

Re: the comparison to subscript, there is one significant difference IMO. This isn’t just about consistency, it’s also about clarity. The current design for multiple trailing closures is more complex than necessary. This complexity reduces clarity. That is part of the motivation for your pitch, isn’t it?

Additionally, there is a common class of APIs that just don’t work well with the current design of multiple trailing closures as well as your pitch. This seems like a problem worth solving to me.

Both of these factors motivate a breaking change. If the first didn’t, I assume you wouldn’t be proposing a breaking change. So the questions are:

  • how much motivation exists here?
  • how much breakage is acceptable given this motivation?
  • how easy will the final design be for programmers to understand?
  • how many use cases will (and will not) be well served by the final design?
14 Likes

Just finally getting around to reading about this pitch.

Overall, with the inclusion of multiple trailing closures, it's inevitable that we will need some sort of forward scan matching for trailing closures. It's simply not tenable to have one set of rules for ordinary arguments and another for trailing closures; and, as you have demonstrated, the latter can produce unexpected and/or undesired results.

However, I'm totally gutted at the state of things, though I stress that this is not a judgment of any person or their efforts. That this has to come down to not one but several attributes being considered (even for staging in this change) is tragic; no user (however advanced) should have to contend with an enum-like attribute and multiple sets of possible syntax rules in order to figure out which argument lines up with which closure.

The crux of the problem

The crux of the problem, as ably described, arises in setting of unlabeled trailing closures when used with multiple possible arguments that have "structural resemblance to a function type":

A possible alternative solution

I do have cause for hope, because I think we have a perfect opportunity to turn lemons into lemonade. Here is my suggested alterative:

The following alternative formulation of how we enable forward scan matching for trailing closures would be (a) less source breaking than what is pitched (in fact, not source breaking at all); (b) tackle the issue named above head-on in a way that the community has broadly approved of; and (c) eliminate the need for attributes that expose "backwards scanning" versus "forward scanning" in the user-facing lexicon.

  1. Give users the option of labeling the first trailing closure. This is purely additive and source compatible, since the option does not exist today.

  2. When users opt into labeling the first trailing closure, they also opt into the forward scanning rule. This is also purely additive and source compatible, since neither a labeled first trailing closure nor forward scanning is supported today.
    These aren't two unrelated changes arbitrarily bundled together. Rather, when a user opts into similar rules about labeling trailing arguments as exist for non-trailing arguments, it is natural then to apply similar rules when determining which arguments line up with which parameters (i.e., to use forward scan matching).

  3. We preserve (for source compatibility, if nothing else) the existing ability to use unlabeled first trailing closures.

  4. When the first trailing closure is unlabeled, the backward scan rule is applied, as it currently is. All existing code continues to behave as it does today.
    This too isn't entirely an arbitrary bundling; rather, I would argue that leaving a closure unlabeled is acceptable if it's not seriously in doubt (to the human reader) which argument label is being elided—in other words, those situations in which the current rule (backwards scan) produces a result that would be considered intuitive to the human reader.

  5. In adopting this set of rules, we avoid doubling down on (i.e., creating new attributes and flags specifically in order to enable, even if only for a transitional period) the confluence of two design decisions that clearly the core team of today would not have adopted de novo—which is to say, arbitrarily unlabeled closures and the backward scan rule.

WDYT?

29 Likes

Would this capability be based on your previous proposal? Just to be clear, this would allow users to choose whether or not to use the first closure label depending on their preference, unless the API designer didn't provide one, right? Sounds like the best of both worlds and doesn't require a major language version. Not to go to far afield, but how do you see the tooling situation playing out? Would sourcekit offer both completions?

I like it, but also want call-site control returned to the callee.

So, I would suggest enhancing your proposal with a non-source-breaking mechanism for an API author to require that trailing closures be labelled. Unless I'm mistaken, that mechanism would need to be an attribute. I'm not sure whether that sort of attribute should be applied at the function-declaration level or the parameter level.

(As an aside, if a caller elects to label a leading trailing closure, in what cases does the direction of the scan potentially make a difference; all parameters would be labelled?)