SE-0279: Multiple Trailing Closures [Amended]

Ah yes, I see. I wouldn't be able to use the trailing syntax to set just the y or z (or y and z) closures, in that case, would I?

Correct, the proposed syntax does not allow you to be explicit about which parameter you're intending to supply, in cases where they're all defaulted. This API, if it existed, would match poorly with this proposal.

-1. More complexity and more special syntax is a bad thing in my opinion. Swift seem to be ever-expanding into having more and more stuff in the language.

No, it's just more syntax and options in an already very complex language.

Personally, I wish swift would have gone the opposite route: making trailing closures an opt-in feature for functions, so that only functions for which it makes sense (e.g. DispatchQueue. and friends) could this special syntax.

No comment. It's hard to say what the feel and direction of Swift actually is with so many moving parts.

N/A

Followed all the threads.

4 Likes

No, I'm saying the more I learn about the complexities this proposal brings to the considerations users have to make around trailing closures, the "worse" I think of it. Swift will now have the following rules, if I'm understanding correctly:

  • Single closure at the end of a function, you can:
    • Use it labeled.
    • Use it with trailing syntax.
  • Multiple closures, all at the end of the function, you can:
    • Use them all labeled.
    • Use all but the last labeled, use the last with trailing closure syntax.
  • Multiple closures, all at the end of the function, with the new syntax, you can:
    • Use them all labeled.
    • Use all but the last labeled, use the last with trailing closure syntax.
    • Use the first as a trailing closure.
      • If the rest of the closures are defaulted, it looks like typical trailing closure usage.
      • If any are not defaulted, you must use the trailing syntax first, then add labels for the additional closures.
      • If multiple defaulted closures are unlabeled you can't disambiguate between them and so can't use the trailing syntax at all. Though this is also true when using them inline (I think), and so may be a bad example.

I really think it would simplify the user's consideration here to make the first closure elision contingent on not having an external label. That way API designers could have some control over this form and not have to move things around for the multiple closure case. Take this (fake) API for something like Alamofire (types elided).

func request(..., modifier: {}, uploadProgress: {}, downloadProgress: {}, completion: {})

In this function, completion is the most important, and likely the only one not defaulted. However, in trying to use this function with the trailing syntax I'd be forced to either create an alternate form where completion is first, which is rather illogical, or use requestModifier as a trailing closure:

request(...) {
    $0.timeoutInterval = 5
} completion: {
    ...
}

Instead, it seems like, by having an external label on the parameter, I'm saying that users should label it, if it's used. We do lose any name elision in that case, but I think that's okay.

request(...)
    requestModifier: {
        $0.timeoutInterval = 5
    } completion: {
        ...
    }
12 Likes

I'm also extremely interested in how the tooling, especially Xcode, will handle the autocomplete here. How will users activate the multiple trailing closure syntax with autocomplete versus inline labels or trailing closure syntax? I'd hate yet another Xcode hack to only enable this for certain APIs, like SwiftUI. It also seems like we'll need some sort of autocomplete when the user is typing multiple trailing closures manually, so how would that work? Is the grammar unambiguous enough to offer suggestions when I start typing the same of another closure parameter? For such a significant change to the language I'd say a solution here would be required for real adoption. (In fact, perhaps it should be something all proposals have to consider in the future.)

10 Likes

-0.5

I like this much better than the first version, and I think I could come to terms with it, although the confusing way this interacts with default arguments seems like it would be annoying to library authors who'd have to jump in and add additional overloads to make their APIs usable, and confusing to users who don't understand the quite complex rules. And that will remain until we get a chance to fix it in a new language mode, whenever that will come to pass.

Overall though, I don't think this goes far enough to be worth it, and I think there are two directions which I would like to see explored:

  1. Apply the proposed syntax to all arguments:

    I think it is beneficial to break out long closures without having them surrounded by punctuation, but I believe the same thing applies to longer normal parameters (e.g. a call to an initializer with multiple arguments, which is itself broken into multiple lines), or just a large number of smaller parameters. Maybe allow omitting the comma in multi-line argument lists while we're at it.

    This would end up similar to one of the more "crazy" examples of what Chris described in the first review thread, which looks quite good to me.

  2. Have a special syntax for closures, but make it feel like building syntax:

    This would be different enough to warrant a completely new proposal, and is a bit more out there, but if we do want special syntax for closures alone, I'd much rather we expand on the precedent of allowing library-authors to build syntax-like constructs, which would mean dropping the : and allowing some way to pass parameters to the "labels" of closures after the first one (which would of course really be passed to the original function invocation).

    I guess—but do now know for certain—this should be parseable, as it is just like saying "there is this other function that takes a trailing closure, but you are only allowed to call it right after calling the original function". Please correct me if I'm wrong on this though :)

In its current form, barely. I don't think having to wrap the whole parameter list, including closures, in () and maybe dropping in a few line-breaks is all too bad for either readability or writability. Omitting those is quite nice from an aesthetic standpoint, but I don't think it's worth it if we're just special-casing closures in a weird way.

Not in my eyes, as I really like the concept of defining syntax-like constructs in library code, and this moves trailing closures away from that direction. If this was just a general thing you could do with argument lists, I'd be okay with it, but as is I don't think it makes sense.

Ruby allows omitting parens around argument lists, and I like that feature very much. Other than that no.

I read both versions of the proposal and loosely followed both review threads, but I've not done that deep a dive on it.

5 Likes

It seems to me there is no loss of clarity because the base name of the method animate describes what the primary closure is doing.

Whereas looking at just the base name of drop:

text.drop { $0.whitespace }

... it's unclear whether this is dropping while there is whitespace or where there is whitespace.

You may have memorized the stdlib surface area and know that drop(where:) doesn't exist yet, but it's a natural algorithm to include as part of a follow up to SE-0270 that adds closure predicate variants of the algorithms.

2 Likes

IMO that would only be true if there was no duration parameter. Since there is, it interrupts any natural association between the closure and the leading verb, so I don't think it provides the context you think it does. Additionally, the content of the closure in the drop { } case can help hint towards what it means since it returns a Bool, while the animate block is just a list of property mutations, with nothing that indicates an animation. Additionally, I don't like the animate example at all, since that's not likely how the method would be written if it were Swift native.

How would you write it?

Let me use UIView.animate as an example for code completion behavior:

UIView.animate<CURSOR>

SourceKit suggests both

  • animate(withDuration: TimeInterval, animation: () -> Void, completion: (() -> Void)?)
  • animate(withDuration: TimeInterval, animation: () -> Void)

as before.

If you select the former one, the code becomes

When you hit enter key at the animations placeholder, SourceKit automatically checks if the rest of the arguments can be trailing closures, and if yes, it turns this whole expression to:

If you select the latter one, since there's only one possible trailing closure argument, the placeholder expansion is exact the same as before

But, if you want to add completion argument after that, SourceKit is able to suggest it after the closing brace of the first closure.

This additional trailing closure completion should work even at the newline position

But in this case, global symbols are also suggested because you might be adding another statement.

11 Likes

Why this special treatment for the first closure, e.g. no label there? Only to make things like:

when(2 < 3) {
  print("then")
} else: {
  print("else")
}

possible? This looks very much like a tailor-made solution for a very special case. I wouldn't mind:

when(2 < 3) then: {
  print("then")
} else: {
  print("else")
}

e.g. having labels for all closures, which appears much more sane to me.

regards,

Lars

7 Likes

+1

The proposed syntax removes a significant pain point when designing and using APIs that take multiple closures. The resulting syntax at point of use is pleasant, clear, and concise. This should be particular true for APIs designed with the new calling syntax in mind. The new API design guidelines around base names are welcome.

I also really appreciate that the proposed syntax and API design guidelines result in calling code that is easy to evolve by adding additional parameters.

The proposal does put some additional burden on API designers to develop an appropriate set of method overloads, especially given the necessary constraints on matching arguments to defaulted parameters. That burden strikes me as a reasonable price to pay for the improved clarity at point of use.

Yes. It’s always been awkward in Swift to call methods taking multiple closures. Existing single-closure methods are also sometimes awkward with a trailing closure; drop { … } being the canonical example. Frameworks like Combine and SwiftUI lean heavily on closures, so the existing pain points have become more acute.

I think so. It’s a comfortable extension of the existing trailing closure syntax that also smooths over some rough edges.

I’ve used C APIs that take multiple function pointers, ObjC APIs that take multiple blocks, and Swift APIs that take multiple closures. The proposed syntax is an improvement on all of them.

I read the entirety of the initial review thread (really!), studied the revised proposal in depth, and experimented with a variety of alternative spellings for passing multiple closures. Of the alternatives I’ve seen, the proposed syntax, when paired with API design that takes advantage of it, offers the best combination of clarity at point of use plus simplicity when evolving calling code.

1 Like

+0.5
I read the proposal and also the Renaming Trailing-closure Functions in the Standard Library. But I will try not to off-topic here.

It’s kind of unfortunate the first labeled closure is omitted in the proposal.

As someone has already mentioned above that the label of the first closure is important in certain APIs and I’m doubted whether it’s possible to remove them all and put into function names.

I prefer first where: { ... } over firstWhere: { ... }.

And also the example Bind(get:set:) from @Lantua. How would you rename it if the first label is omitted? Abstract that by protocols seems too heavy to me.

But in some cases, omitting the first label is good and don’t harm readability.

Even from the teaching/learning perspective, I believe no special case is always a better choice. We have to learn how to use multiple closures anyway if the proposal is accepted. No matter how many closures a function takes, one or many, the first or the remaining, all follow the same rules.

To summarize my points, I think I would prefer a more balanced solution for the first labeled closure.

4 Likes

Another example from SwiftUI where the omission of the first trailing closure label feels strange:

Binding { 
  self.value 
} set: { newValue in
  self.value = newValue 
}

Ideally I'd prefer to use the first closure label explicitly:

Binding
  get: { 
    self.value 
  } 
  set: { newValue in
    self.value = newValue 
  }

@Lantua already mentioned this briefly above.

11 Likes

Having put more time in reading the points in favor of requiring a label also for the first closure, I can say that now I 100% agree with those, for a variety of reasons. The first, and most important, is that it doesn't make any sense to treat the first closure as "special", even if this is the current Swift behavior (in the sense that the current trailing closure syntax omits the argument label). For a lot of APIs, both existing and potential, skipping the the label for the first closure would make the code harder to understand at call site, there's no getting around it.

The only special rule I'd suggest, to make things like the when example possible, is the possibility to omit the first argument when it's _:.

9 Likes

The proposed syntax is a step forward as compared to the previous iteration.

I do appreciate the effort that has gone into making truly multiple trailing closure expressions a reality--for instance, making it easier on the parser when it comes to default:.

I understand the motivation that some closures seem more important than others in a function call and therefore providing some way to elide one argument label. It is certainly elegant in that favorite toy example of ours, when(then:else:).

However, as the review feedback has shown, it is quite possible to provide many examples where there are multiple equally important closure arguments. I would therefore caution against overfitting to APIs we already have, since those have been designed explicitly for a language where only a single trailing closure syntax is available.

Moreover, we have seen examples where it might be optimal to label the closure even when there's only one argument. This is not only for cases where the label might provide call-site clarity. @allevato makes an excellent point that a labeled trailing closure syntax would solve the issue where existing trailing closure syntax can't be used in the condition of an if statement without parentheses:

My principal concern, however, regarding first-closure label elision is the "backward scan" rule.

By construction, if there are multiple closures, and one label may (or, in this version of the proposal, must) be elided, then there must be some rule to figure out which argument corresponds to the unlabeled closure. This is not just a difficulty for the machine, but also for the human reader.

I appreciate that much effort has been made so that it's more predictable or consistent which argument is unlabeled if an additional closure argument is added, or if it is of optional type, or if a default value is added. The degree of explanation required, however, for the reader to determine which argument is the unlabeled closure is mindblowing, as reflected in the length of text devoted to that purpose in the proposal. And if it is to be redone for Swift 6, all the more so.

Certainly, the backward scan rule is not teachable even if one might argue that it is the most usable possible rule. However, I am skeptical than any rule for matching unlabeled closures to arguments, even if optimally redone for Swift 6 in a source-breaking way, would be more usable than not having to have a rule at all (that is, by requiring all labels).

I do not think that the requirement for a language rule, whether "backward scan" or the possible source-breaking future "forward scan," is an adequate tradeoff to avoid repeating the word "animate" at the call site.

For that reason, and for the reasons above, I agree with @jrose that the rule should be (as was proposed in the first review): if you have multiple closures, all must take an argument label; if you have exactly one trailing closure, the argument label may be omitted.

The ability to omit the label for a single trailing closure expression goes some way (in the vein of the 80% rule) to ameliorating the use case where one closure is more important than the rest (limited, obviously, to cases where "the rest" have default values or are optional).

The core team having already decided that the underlying motivation for the proposal does merit a change, I will consider this question to be out of scope for this re-review.

See above.

I have used the trailing closure syntax already present in this language. I made the suggestion during the first review that was the basis for this revised syntax, so I'd say I have some familiarity with the topic (speaking of, some acknowledgment might be nice?). I have read the diff and followed along with the implementation.

21 Likes

(My apologies if this has been discussed before, as I didn't have the time to read the full thread)

I worry that this proposal adds additional complexity to an already overcomplicated feature. Please keep in mind that trailing closures are optional, in that a user is never required to use them, yet library designers often design their APIs with trailing closures in mind. However, for many newcomers, trailing closures are a confusing and alien-looking feature. I've seen many students avoid them for months, because they find it easier to learn the language using regular closure arguments, which are more consistent with the rest of the language.

Is it not possible to resolve this "first parameter" discussion by removing complexity from the language, instead of adding it? If the Standard Library is open for changes, can we not simply remove the exception that a trailing closure argument drops its argument label, and return to a state of symmetry where it's up to a library author to decide whether an argument requires a label or not, and that this label is required, both for a regular closure argument, and for a trailing closure?

26 Likes

What do you think of the slight variant suggested by @xwu and @jrose below (amended by me for clarity)? It also simplifies the rules, and would break less existing code:

I just made a run through Xcode doc. The first thing I observed is that multiple-closure is rare, which is expected of pre-SE-0279 era. Nonetheless, I found a few of them. The proposal already mentioned animate function, and Section init. I wanted to add that, currently, there aren't a lot of other functions outside of that. Anyhow, they all generally fall into these categories:

Action & Completion

They are functions of the form:

func doSomething(thatSomething: (Param) -> (), completionHandler: (Result) -> ())
Boring list

There's also a large subset where action is the animation blocks, which can be found in pretty much any animation-related functions in
UIView, UIViewController, UIViewPropertyAnimator, UIViewControllerTransitionCoordinator, UIFocusAnimationCoordinator, UIGraphicsRenderer. An example includes the discussed animate(withDuration:animations:completion:).

This would match @John_McCall's conjecture earlier that functions would have one main closure (action) and a few lesser closures. Note still that some functions in these category have defaulted (action) closures, so it doesn't fit the mould perfectly.

Event Handlers

They are functions that sets a few event handlers together. Most notably in Combine's Publisher functions:

Boring list

These functions fit more to @jrose's concern where none of the closures are mandatory (breakpoint, handleEvents), or all of them are (sink).

Notably, there're also a lot of functions that use one completion handler like so:

doSomething(..., completionHandler: (Succeed?, Error?) -> ())

Arguably this could be split into succeed closure and error closure, but it could also use Result type, or even async-await.

DSL

These are mostly SwiftUI functions, that has one main View closure, and a few accompanying closures. Some also have another View closure for nested contents.

Boring List

Misc

There are two functions I found so far that don't really fit into any category

So I'm inclined to agree that this is more of an exception rather than the norm.


From what I see, an overwhelming majority are animate & completion functions. All other categories become so rare that I did just listed all functions that I found. One caveat is that the API frequency doesn't translate to usage frequency.

Edit: Just added some more... Don't mind me :stuck_out_tongue:

16 Likes

Thanks for taking the time to compile this!