SE-0279: Multiple Trailing Closures [Amended]

I'm convinced this is a problem worth solving and I'm fairly happy with the new proposed syntax, but I agree with Jordan's suggestion that if a call has multiple trailing closures, they should all be required to have argument labels.

The proposal says:

If labelling the first trailing closure were allowed, users would have to evaluate whether to include the label on a case by case basis, which would inevitably lead to linter and style guide rules to prohibit it.

If labeling the first trailing closure was required if there was more than one, the scope of this problem would be reduced somewhat. Furthermore, I'm not sure the fact that style guides might mandate a particular choice is sufficient motivation not to offer the choice at all. For example, many Swift style guides require or prohibit the use of trailing commas in collection literals, but I think most would agree the language shouldn't enforce a single approach. I'm not convinced the situation in this case is significantly different.

I also have some concerns about the future source break this proposal considers:

The behavior of this call can’t be changed without potentially breaking source compatibility. That might be worthwhile to do in order to enable these sorts of APIs and get more consistent type-checking behavior for trailing closures; however, it will need its own proposal, and it will only be feasible under a new source-compatibility mode. We recommend considering this for Swift 6. In the meantime, library designers will have to use overloading to get this effect instead of default arguments.

I'm hesitant to support a proposal which introduces confusing typechecking behavior and would encourage a nontrivial source break in a future version. Again, it seems like requiring the first trailing closure to be labeled would eliminate the need for a backwards scan when matching arguments. Instead, the arguments could be matched as if they were all parenthesized.

3 Likes

The existing type-checking rule for trailing closures aggressively matches the argument with the last parameter of function type (more or less). This happens even if that parameter has a default argument and an earlier parameter doesn't — and therefore the call won't type-check because that earlier parameter is left without an argument. Those of us working on the language broadly agree with you that this is a bad and unintuitive result. Unfortunately, we can't change that behavior when there's only one trailing closure without breaking source compatibility. While we could use a new and better rule when there are multiple trailing closures, it would often create a sort of "trap" where programmers write functions like that and then find they don't work when the user drops down to a single closure. For the time being, programmers will have to use overloading to get that effect. We hope to propose a change to the type-checking rule, but it will only be able to take effect in a future language compatibility mode, maybe Swift 6.

4 Likes

I think overall this is going in a much better direction compared to the previous iteration.

I also agree that it should require the first closure label. As is, it somewhat signifies that the first closure is more significant compared to others. This may not be true in some context, or that the most significant closure is more appropriately put elsewhere. Like the Section example, imo it would be more intuitive to do

Section()
  header: { ... }
  content: { ... }
  footer: { ...}

even though content is the most significant closure.

7 Likes

We may also require the parentheses if things like this:

foo a: { ... }

is of parsing concerned. Though I do not exactly know if that’s the case.

It's not more problematic to parse that than it is to parse labeled trailing closures in general.

1 Like

I do think that in practice with APIs like this there's almost always a primary closure that's natural to write without a label: the primary completion handler in contrast to the error handler, the main contents in contrast to header/footer contents, or something along those lines. For example, with that Section API, if you didn't provide header/footer contents you'd absolutely expect to just write the contents in an unlabeled trailing closure. It's a bit odd to have to go back and put a label on that closure just because a header or footer is added afterwards; we don't generally expect adding arguments to disturb earlier arguments. So I think there's an abstract appeal to labeling all the closures — certainly it was my first instinct — that might not match reality very well.

It's also worth pointing out that, if lived experience shows that labelling the first closure is really valuable for a class of APIs, that will be a very straightforward future extension.

6 Likes

+1

Looking forward to this. I think control flow is what trailing closures need to work best for since that is the original purpose. I think the proposal succeeds in that regard. I think it is a natural way to separate configuration from content. This worked well for XAML. I think it will be great for SwiftUI and other declarative DSLs.

There continue to be ambiguity issues with trailing closures. This solves the immediate needs while kicking the can down the road on the remaining issues. I think it is fine to hold off where possible on syntactic ambiguity until major goals of the language are completed.

Yes. It helps to flesh out the DSL features coming to Swift and is a step toward possibly closing an ambiguity in the language.

Yes. Swift aims to add reactive and declarative programming features where this is a natural fit. This feature naturally extends trailing closures without adding a third convention to the language. It addresses existing uses for trailing closures where multiple closures were awkward and therefore avoided.

I am not aware of another general purpose language that uses multiple trailing closures, however it is reminiscent of XAML which works well to separate content from configuration. It is different then XAML since strict ordering is required. However, I think this proposal is a better fit for Swift.

I read the full revised proposal and the original proposal. I have been using Combine and SwiftUI significantly since they were introduced last year. I feel this is a good solution in those domains.

I like that too, but I agree with the proposal that it is too big of a syntax change for Swift 5. That ordering could be added with an overload in the future. In fact you would want both versions since not all lists would have a header.

Question to the proposal designer/implementor & compiler devs: is the label colon really necessary in this version/design?


Proposal feedback:
Personally I‘m okay-ish with this iteration. It‘s better than the first one even tough still not perfect, but that‘s my personal preference. I can live with most examples from the proposal except the one from SwiftUI, it doesn‘t look much better to me. Again this is also my preference here.

3 Likes

Something is required or it will clash with keywords, function calls, and variables. A colon is natural because it looks like a label.

EDIT:
Everyone has their own opinion, but my take on why this might be better for SwiftUI is for two reasons. It separates content from configuration. There are similar DSLs that adopt this method including XAML and to a lesser extent HTML. It helps parentheses matching work. If your parentheses are off the screen, that is too far to easily follow the code. Potentially you could code fold parentheses, but that doesn’t feel natural to me. It feels like I’m folding an expression.

2 Likes

The general preference of users for trailing closures, but the lack of a syntax for multiple trailing closures has left API authors in a bind. When it came to closures, we were forced to violate an important API guideline:

  • Prefer to locate parameters with defaults toward the end of the parameter list. Parameters without defaults are usually more essential to the semantics of a method, and provide a stable initial pattern of use where methods are invoked.

The guideline says methods should have a "a stable initial pattern," but because trailing closures were liable to be unlabeled it was important for clarity to prioritize its stability. This is why (I presume) Combine's sink varies like so:

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  }

ipAddressPublisher
  .sink(receiveCompletion: { completion in
    // handle error
  }) { identity in
    self.hostnames.insert(identity.hostname!)
  }

However, I don't think we should continue contorting our APIs in this way. It's more consistent with API guidelines (and at least one API author's intuition :sweat_smile:) for the "optional" parameter to come last:

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  }

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  } receiveCompletion: { completion in
    // handle error
  }

I think UIView.animate(withDuration:animations:completion:) bears this out. It was designed in Objective-C in accordance with (at least the spirit of) the API guideline and with no trailing closure syntax to contort it. It translates naturally to multiple trailing closure syntax:

UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
}

UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
} completion: { _ in
  self.view.removeFromSuperview()
}

You're absolutely right that if we go in this direction, APIs like Combine's sink that fit themselves to a single trailing closure world won't translate well and will need to be tweaked by hand. But I think this is a cost worth paying to bring trailing closure syntax into alignment with a fundamental API guideline.

6 Likes

I totally agree and I don’t think this is an issue in practice. Combine could add both ways with an override and deprecate the old way.

From my perspective, it's largely a misuse of the syntax to only pop out of the parenthesis an arbitrary subset of the trailing closures. I think it would be a mistake for us to weight this consideration too heavily.

The API guidelines emphasize clarity at the point of use, in particular:

When evaluating a design, reading a declaration is seldom sufficient; always examine a use case to make sure it looks clear in context.

I think it's important to go one step further and focus on "real-world" (or at least "realistic") uses cases. The striking thing about the APIs I found in my research was that they tended to have a "primary" closure, which didn't require an argument label in order to be clear (at least in my opinion):

UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
} completion: { _ in
  self.view.removeFromSuperview()
}

Props to @ricketson who was the first one (AFAIK) who observed this. An animations argument label would be totally redundant:

UIView.animate(withDuration: 0.3) animations: {
  self.view.alpha = 0
} completion: { _ in
  self.view.removeFromSuperview()
}

... making it a violation of the API naming guidelines:

  • Omit needless words. Every word in a name should convey salient information at the use site.

Combine has an unlabeled sink variant:

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  }

... and SwiftUI has an unlabeled Section variant:

Section {
  // content
}

The authors of these APIs established names and conventions to make the role of these closures clear without a label. Appending additional labeled closures does nothing to make the initial unlabeled closure less clear.

I see the appeal of the order of the closures matching the vertical display order for SwiftUI's Section. Whenever possible we tried to craft the SwiftUI APIs to evoke the UIs they represented and this is in keeping with that.

However, I'm wary of us over-fitting to SwiftUI's Section use case. It's my intuition that, most of the time, the label on the first closure would just be a "needless" word.

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  }

ipAddressPublisher
  .sink receiveValue: { identity in
    self.hostnames.insert(identity.hostname!)
  } receiveCompletion: { completion in
    // handle error
  }
3 Likes
  • What is your evaluation of the proposal?

+1. I’m happy with where this ended up.

I do think we need to followup with a proposal to fix standard library API which will no longer meet the revised API Design Guidelines (such as Sequence.first(where:).

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, functions that take multiple closures have always been a clunky aspect of Swift’s syntax.

  • Does this proposal fit well with the feel and direction of Swift?

Very much so.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

N/A

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A quick read.

1 Like

I appreciate the pushback; what I don't see is the evidence why the first closure should be the one with the needless word, if there's more than one. That's probably true if there's one mandatory closure argument and one or more defaulted ones, but I'm not sure it's true for multiple mandatory arguments, or all defaulted closure arguments. Of course this does become a new guideline, but that doesn't mean it won't be at odds with other guidelines, such as today's "put trailing closures last" overriding "put [non-closure] default arguments last".

…I'm honestly not sure if that last sentence means my concerns are well-founded or unfounded.

P.S. I wish we could take @mayoff's restriction "you can't just use some trailing closures" to keep the number of possible call sites to two if the naming guidelines don't change, but that would break existing code.

5 Likes

We definitely can. I doubt we ever will. The two design do differ enough that, whichever one we adopt, the amended guideline (and the soon followed APIs) will be driven by the syntax. At that point, the other option will become very unappealing.

If we omit the first label, Section would look one way, if we require it, it would look another.


That’d be the problem in our debate. We can only assume what the majority looks like without much of a cold-hard-data. I speaks of SwiftUI only because that’s the framework I’m most familiar with utilizing calls with multiple closure, while yours UIKit. Since I can easily grab Binding(get:set:) to be the opposite.

2 Likes

+1

I've been thinking the same thing. This is really unclear:

text.drop { $0.whitespace }

It might be worth moving the argument label into the base name:

text.dropWhile { $0.whitespace }
text.dropWhere { $0.whitespace }
1 Like

or...

text.drop while: { $0.whitespace }

:wink:

9 Likes

Assuming you're not advocating for all single trailing closures to start requiring argument labels, my problem with this spelling is that it leaves it up to the user to decide on a method by method basis whether the argument label is required for clarity.

I think if the argument label can be dropped, the API author needs to assume it will be dropped, and name the method accordingly.

3 Likes

The prominent examples I found in the SDK (UIKit's animate, Combine's sink, SwiftUI's Section) were all naturally modeled as one mandatory closure and one or more defaulted ones. Though @Lantua pointed out Binding(get:set:), which is an example of multiple mandatory closures.

My intuition is once you start to have multiple mandatory closures, it's worth considering whether you might be better off declaring a protocol, Binding(get:set:) notwithstanding.

+1