Multiple Trailing Closures

Swift currently supports a special syntax for a single trailing closure which makes it possible to pass a closure as function's final argument after parentheses as a block without a label. This is very useful when the closure expression is long. I propose to extend this functionality to cover multiple closures instead of just one.

There has been some discussion about this previously at Pitch: Double (or more) trailing closures but since last September discussion has died down.

I'd like to pick it up and propose for new syntax we keep brace notation but allow the "body" enclosed in braces to have multiple labeled blocks, one for each trailing closure, that fits well into an existing model we have for calls as well as easy to implement in the parser.

In pseudocode it looks something like this:

<function, subscript, or contextual member> {
  <label-1>: { ... }
  <label-2>: { ... }
  ...
  <label-N>: { ... }
}

Let's consider following function (slightly modified from original pitch):

func when<T>(_ condition: @autoclosure () -> Bool, then trueBranch: () -> T, `else` falseBranch: () -> T) -> T {
    if condition() {
        return trueBranch()
    } else {
        return falseBranch()
    }
}

If new syntax gets adopted it would be possible to call it like this:

while (2 < 3) {
  then: { 3 }
  else: { 4 }
}

It also makes syntax more consistent and clean in combination with defaulted arguments e.g.

func transition(with view: View,
                duration: TimeInterval,
                animations: (() -> Void)? = nil,
                completion: (() -> Void)? = nil) {
}
transition(with: view, duration: 2.0) {
  print("which arg is this?")
}

It's not very clear just by looking at this code which argument is used because both of them are defaulted, so for readability it's much better to supply the label explicitly:

transition(with: view, duration: 2.0) {
  completion: {
    ... 
  }
)

or

transition(with: view, duration: 2.0) {
  animations: {
    ...
  }

  completion: {
    ... 
  }
)

I have an implementation for this new syntax available at [SE-0279][Parser] Add support for multiple trailing closures syntax by xedin · Pull Request #29745 · apple/swift · GitHub and going to post snapshots here once they become available.

12 Likes

It would be good to see comparisons to the status quo for your examples. For your first, quoted above, the current usage would be:

when(2 < 3,
    then: { 3 },
    else: { 4 }
)

That doesn't seem like much of a gain to me, especially with the particular requirements for autocomplete inside the encompassing trailing closure.

15 Likes

Yes, the only other difference is need for comma between then and else in current state. It's also possible to get autocomplete working the same way as with regular arguments (because these are regular relabeled arguments). I think the biggest advantage comes when these closure arguments grow in size especially in SwiftUI.

Even with the new syntax, once inline closures exceed a particular length they should be pulled into separate functions. I'm not sure your proposal changes that calculation.

Ultimately I think I would need to see what the autocomplete and automatic formatting in Xcode looks like to say whether this would be an overall win. It's growing on me.

1 Like

I see the appeal of trying to make a feature more general, but in this case I have doubts about whether this pulls its own weight.

In the animation example, for instance, the status quo is--

transition(with: view, duration: 2.0,
  animations: {
    ...
  },
  completion: {
    ...
  }
)

/* versus the pitch here:
transition(with: view, duration: 2.0) {
  animations: {
    ...
  }
  completion: {
    ... 
  }
}
*/

--so the pitch offers a second spelling that isn't any lighter at all, just different. (By contrast, in the case of single trailing closure syntax, it's sugar that does make the call site lighter both by dropping the label and by avoiding nesting two 'layers' of punctuation: neither is achieved here.)


One other advantage of the trailing closure syntax is that it gets us closer to something approximating native control flow syntax, useful for DSL-related situations.

However, as you show with your example of when, the else block is counterintuitively nested within the condition that it's supposed to contradict using your proposed spelling, which far from approximating native if...else is totally alien to it.

Moreover, it seems these blocks are easily confusable with labeled do blocks; I imagine it could actually inhibit the compiler error we have today to remind users to add the missing keyword do when they label naked braces. Granted, this isn't a commonly used feature, but I think it's a double drawback that when...else both can't resemble native control flow syntax that ideally it would resemble and must resemble native control flow syntax that ideally it wouldn't resemble.

Ultimately, what's pitched here suffers from not having a solution that actually offers multiple trailing closure expressions, but rather a single trailing expression that contains multiple closures.


But enough of bikeshedding the actual spelling. I think overall my bigger concern is related to the first thing I've commented on. The idea imposes the burden of learning a second syntax for something that's already expressible, a syntax that isn't purely a natural extension of the single trailing closure expressions we already have.

If there were some huge win in terms of enabling use cases (or even a single huge use case, in the same vein as how the dynamic member lookup and dynamic callable proposals enable usable Python interop), then I could see why we need such sugar. But based on the examples here I worry that it's adding much more to the language than we would get in return.

14 Likes

Would it be possible to make multiple trailing closures work?

when(2 < 3) { 
    3 
} else { 
    4 
}

and

transition(with: view, duration: 2.0) {
  // animations ...
} completion {
  // ... 
)

The idea here is that the first trailing closure's label is elided while all other trailing closures parameters must have a label that is used between the closures. I omitted the colon after the label in the above examples, but it would also be possible to include it, (i.e. else: and completion: in the above example). Requiring the colon might make sense for implementation or clarity reasons - i.e. maybe it's good to have a colon to distinguish trailing closures from built-in control flow.

My guess is that this would require too much context sensitivity in the parser even if it's technically possibly to implement, but maybe I'm wrong about that. There are certainly plenty of use cases that would be cleaned up if something like this was possible.

7 Likes

If technically possible, I think that could change the pros and cons of the idea. But with the caveat still that we'd be introducing a new syntax for what is a small fraction of the use cases for single trailing closures.

Closures are already associated with so much unique sugar (useful ones, often, but still) that I think we really need to hold ourselves to a high bar for adding any more; the possible negative effects on readability and learnability (and therefore usability) are not to be underestimated.


That said, I don't think it'd be possible to accomplish in any robust way. At best it would lead to quite the unprecedented situation where the following two examples have totally different meaning:

func foo(_: () -> Void, bar: () -> Void? = nil) { /* ... */ }
func bar(_: () -> Void) { /* ... */ }

foo {
  // ...
} bar {
  // ...
}
// versus
foo {
  // ...
}
bar {
  // ...
}

I recall that this was discussed at length in the preceding pitch, and what is presented here in this pitch reflects a polished version of a local optimum in terms of what's achievable. We could basically start over again but I think it's useful to consider the pitch here on its own terms.

7 Likes

If we required colons on the labels then it would be


foo {
  // ...
} bar: {
  // ...
}

In this approach,


foo {
  // ...
} 
bar: {
  // ...
}

would have the same meaning as the former (only calling foo), while:


foo {
  // ...
} 
bar {
  // ...
}

would call both foo (with the default argument of nil for bar) and also call bar.

So the label colons would help disambiguate, but it’s possible (maybe even likely) that they’re still not enough to make it work.

2 Likes

I don't think that's actually possible in case of SwiftUI, that's when these closures are the biggest.

Such motivation, at least for me, is SwiftUI because it has a lot of APIs which require either a mix of regular arguments and trailing closure or just spelling everything out e.g. Button has action: and label: arguments, transition API is the same way. So I think it would be nice to unify all of that by enabling this kind of syntax which is much easier to parse comparing to alternatives proposed in the previous thread.

1 Like

I agree with @xwu. This isn't really multiple trailing closures.

I don't think that this works in principle. You end up having to type the parameter labels, which defeats the point of using trailing closures in the first place. Consider:

If this kind of syntax is desirable, then why not take it one step further? Why not go all the way and allow all the function arguments to be within the curly braces? Why even have 2 different kinds, separated in to distinct groups? It wouldn't really make a lot of sense, and it would just be annoying for programmers.

Case in point: the example I just quoted erroneously used a closing ) instead of a closing }. So let's unify them:

transition {
  with: view,
  duration: 2.0,
  animations: {
    ...
  },
  completion: {
    ... 
  }
}

But now, with everything using the same closure syntax, it's more difficult to reason about captured variables. Okay, let's fix that by making the entire outer block use (...) braces:

transition(
  with: view,
  duration: 2.0,
  animations: {
    ...
  },
  completion: {
    ... 
  }
)

aaaand we're back to Swift's current syntax. By making things simpler, and then readable, we arrived right back where we started.

IMO, that is an indication that the feature is fundamentally flawed. It basically gives functions two parameter lists, each enclosed in different kinds of braces. Unlike single trailing closures, you can't omit anything that would be required in a "regular" function call, and so it hasn't made anything simpler. If anything, it makes things more complex.

10 Likes

Thanks for the feedback, @Karl!

That's also exactly what a single trailing closure does too. I think there is a point to such separation because it allows to effectively group arguments by kind.

I think this is the key takeaway here—the proposed syntax doesn't deliver what its name says it is.

Trailing closures let the user move the closure outside the function call argument list and omit its label in a way that provides a significant syntactic improvement by creating something that looks like other control constructs in the language.

Naturally, eliding the labels can't be done with multiple closures, but the proposed syntax doesn't feel like it holds its weight, but that it just shifts some punctuation around. Having two extremely similar syntaxes that do the same thing would be confusing, and it's oddly similar enough to existing syntax that has a much different meaning, distinguished only by the colons:

// A function that takes two final closure arguments, foo: and bar:
someFunction(args) {
  foo: { something() }
  bar: { somethingElse() }
}

// A function that takes a single trailing closure, which contains calls
// to functions foo and bar that take trailing closures
someFunction(args) {
  foo { something() }
  bar { somethingElse() }
}

I'll second the recommendation from @anandabits that this feels like the syntax needs to resemble other control constructs more closely to fit in the direction of Swift, but as shown above that seems difficult to achieve:

  • The grammar shouldn't mandate a syntactically-significant newline (or lack thereof) to distinguish a function call with multiple trailing closures vs. two separate statements each with a function call and trailing closure, because that may make it impossible for users to match trailing function call usage with their preferred line wrapping style for other control constructs. For example,
// OK                  // Not OK
if condition {         foo {
}                      }
else {                 bar {
}                      }
  • Forcing the colon after the label also makes the syntax diverge from other control constructs, and maybe it could still cause parsing confusion with labeled statements? I'm not sure how much the parser can look ahead there to resolve ambiguities.

Without getting to that syntactic "sweet spot", I think this idea either introduces a syntax that is too close to the existing syntax to be useful, or introduces a new syntax that is awkwardly different from both regular function call syntax and the control construct syntax achieved by regular trailing closures.

5 Likes

I agree with the concerns voiced above. If we have to specify labels for the closures to make it understandable, what are we really gaining other than a strange special kind of closure looking thing that isn't really closure but instead a container for other closures that are named with a label.

For me this is too similar to what Swift already has to be useful.

2 Likes

No, single trailing closures aren’t enclosed in braces. This proposal suggest one set of parameters enclosed in () followed by a second set enclosed in {}

1 Like

Also, on a meta level, I think it's premature to tweak Swift's syntax for SwiftUI (or in general, for function-builder DSLs). They are not an official/public feature, so most of us can't use them for any real projects. We don't have the opportunity to encounter these difficulties for ourselves, which makes even evaluating the problem quite difficult. AFAIK SwiftUI is the only library using the feature right now, so if you don't use SwiftUI (e.g. if you use Linux, or need to support iOS 12), you'll never encounter a function builder today.

Once the feature becomes public and more libraries use it, we can all better understand any shortcomings and suggest improvements.

4 Likes

Bikeshedding syntax:

func test<T, E>(
  _ condition: @autoclosure () -> Bool, 
  success: () -> T,
  failure: () -> E
) -> Result<T, E> where E: Error { 
  if condition() {
    return .success(success())
  } else {
    return .failure(failure())
  }
}

try test(someCondition)
  // Trailing 'labeled' closure modifier
  .success: { ... }
  .failure: { ... }
  // Operation on the result type
  .get()

This would be so similar to regular chained method calls (the only difference being the colons after the label) that I think it suffers from one of the same confusability problems that the original proposed syntax does—in this particular case, the difference between a multi-closure function vs. a chain of method calls is far too subtle.

It would also be odd to use a dot to introduce the next argument label, since that would be overloading dot with a completely new meaning (and it wouldn't be done in the declaration, so there's another inconsistency). It doesn't seem like it cleans up the syntax, because the dot isn't being used in a functionally different way from a comma; it's just been moved to the beginning of the next line instead of the end of the previous line.

1 Like

Unfortunately using . would be really hard to disambiguate with name lookup. That's where IMO brace syntax makes it easier e.g.

try test(someCondition) {
  // Trailing 'labeled' closure modifier
  success: { ... }
  failure: { ... }
  // Operation on the result type
}.get()

It makes it easy to understand that get() is performed on the result of the call but visually grouping arguments together.

1 Like

I'm not opposed to your syntax, I was just adding another syntacticly appealing option (at least to me). ;)