SE-0279: Multiple Trailing Closures

This is what I was referencing, but if I'm reading it wrong then great! I prefer the syntax proposed by you and Chris. I'm hopeful this is adopted over the proposed syntax.

I thought of another small but possible advantage of the alternative form that uses no braces around the trailing closures but keeps the label.

I would +1 the syntax with labels before brackets. I could see it clearing up complex code in SwiftUI and Combine.

EDIT: Fixed typos.

  • What is your evaluation of the proposal?

-1, but +1 for proposed alternative syntax that fits better with Swifts existing trailing closures. The alternative feels like a small enough change to the existing language and may clarify some ambiguity.

This proposal is obviously aimed at improving SwiftUI and declarative programming in general, so I'm trying to look at it from this view.

    // Current state of things.
    Button(
        action: {
            doSomething()
        },
        label: {
            Text(text)
            SomeView(
                action: { doSomething() },
                label: { 
                    Text(text) 
                }
            )
        }
    )
    .buttonStyle(BorderlessButtonStyle())
    .padding(.bottom, 2)

    // Alternative syntax
    Button()
        action: {
            doSomething()
        }
        label: {
            Text(text)
            SomeView()
                action: { doSomething() }
                label: { 
                    Text(text) 
                }
        }
    .buttonStyle(BorderlessButtonStyle())
    .padding(.bottom, 2)

The major advantage, as shown in the example, is this avoids the alternating parentheses and curly brackets which are hard to read. I think this was the major reason why trailing closures were added to the language. Expanding the existing trailing closure syntax to help with this makes sense to me.

I'm not sure how unlabeled closure arguments might fit in to this syntax. Maybe they would not be allowed. I'm also not sure if unlabeled closures should be allowed when multiple closures are present.

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

Yes. SwiftUI, reactive programming, and declarative programing are big areas for development. Helping to avoid alternating parenthesis and brackets without dramatically changing existing syntax for these features is worth it.

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

The alternative to this proposal feels like it fits in with past decisions. Certainly all sugar-only proposals are highly debatable.

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

The closest would be Ruby that does trailing closures for similar syntactical reasons. However, Ruby is limited to one closure and is much more limited than Swift in how it can call closures. I'm pretty sure Swift's use of trailing closures was inspired by Ruby.

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

I read the proposal and most of the forum posts. I probably spent way too long thinking about this.

I'm not sure how I feel about the alternate proposal in this context (preceding a labeled for loop):

func foo(bar: () -> Void, baz: () -> Void) {
    //...
}

foo
    bar: {
        // ...
    }

    baz: {
        // ...
    }

qux: for i in 0...10 {
    if i > 5 { break qux }
}

It wouldn't cause any real strain on the parser (the token following the label: won't ever be an open bracket) but it does cause some strain for the reader. At first glance it looks like qux: is another parameter to foo (if not for indentation.)

3 Likes

An interesting point, but I think the proposed syntax will mostly appear in functional/declarative programming, so they probably will not mix with for loops too much in the real world.

EDIT: In fact this is the type of for loop we will likely see in the same context:

VStack(alignment: .leading) {
    ForEach((1...10).reversed(), id: \.self) {
        Text("\($0)…")
    }
}

EDIT 2: Honestly, this somehow makes this syntax feel more appealing to me since there is another area of Swift that uses labels outside of an argument list.

The "if not for indentation" part is a significant differentiator, though. If code authors format their code well by indenting properly (or use tools that do it for them), this seems like a non-problem. You've also made the interesting choice of putting a blank line between the two trailing closures in that call, which I would argue is a bad formatting choice, since the lack of a blank line between foo and the first closure would then make it appear that baz is somehow conceptually separated from foo and bar. This example feels intentionally crafted to be more difficult to read, and no amount of syntax can prevent an author from doing that to their code if they want.

Labeled loops also occur fairly rarely (in my experience), certainly much more rarely than trailing closures, so I'm not sure there is too much potential for (human) ambiguity there in practice.

3 Likes

Indentation is important, though. It doesn't have to be significant to the compiler, but it's clearly significant to the reader.

Labels are very uncommon in my own code, but when do I use one, it's always on a line by itself with a hanging indent, so for me your example would come out as:

func test() {
  foo bar: {
        // ...
      }
      baz: {
        // ...
      }
qux:
  for i in 0...10 {
    if i > 5 { break qux }
  }
}

I don't think I would find this confusing at all, and that's without any vertical space between the call and the label.

2 Likes

It's also important to remember in these readability discussions that real APIs are not called foo(bar:baz:). Argument labels will reinforce the connection of argument closures to their calls, and loop labels will generally look like something clearly different and unrelated.

6 Likes

All valid counterpoints. My only intent was to highlight something that hadn't previously been brought up (the closest syntax "relative" in currently valid Swift to what's being proposed, and how they might collide.)

My main concern is that without a more obvious delimiter, it's difficult to see where the function call ends. This could become easier with time and exposure, though.

1 Like

That's just how I'd write it :man_shrugging: Wasn't intentional.

FWIW I've since changed my original opinion on the proposal's syntax — enough counter examples were given that I no longer support the syntax as originally proposed. Not sure how I feel about this new syntax. With better code completion and automatic indentation help from Xcode I feel like the current syntax would be Good Enough :registered:.

I've voiced my concerns about the braceless syntax already, so I won't repeat myself here, but I hope that if the core team wants to move in that direction, there will be be another round of review with that syntax rather than accepting the proposal with modifications. I have major issues with that syntax, and I think it needs to be fully examined on its own before becoming part of the language.

4 Likes

I’ve looked but cannot find your previous posts on the subject. What are the major issues you have?

Yes please. It’s already been stated in previous discussions that review threads are not the right place to debate counter-proposals.

This thread has become a tangled mess of people talking over one-another with their own sketches of how this feature should look. Many of those are interesting and deserve further discussion elsewhere, but right now they just make the thread exhausting to read. It’s like some kind of endurance event.

4 Likes

I really do want to hold back for now because I don't think this is the right place for that discussion. In the mean time, clicking on a user's avatar shows a filter button that will show you their posts in a topic :smile:

1 Like

The Core Team will definitely not accept a major redesign as a modification without further review.

14 Likes

It's precisely the same progression we have for computed properties and subscripts from, "there's one thing and it's obvious what it does" to "there are multiple things that all need to come together". The shorthand for get-only computed property:

var x: Int { currentPosition.x }

turns into an enclosed set of accessors when we add a setter:

var x: Int {
  get { currentPosition.x }
  set { currentPosition.x = newValue }
}

or when there's only one but it's not the default, obvious thing:

var totalSteps = 0 {
  didSet { listener.updatedSteps(totalSteps) }
}

The outer set of curly braces could have been made unnecessary in the grammar of computed properties, but that's not how Swift is designed. The same arguments that applied back then to computed properties---about this being a group of related pieces that should conceptually be held together rather than left to juxtaposition or indentation---apply to the proposed multiple trailing closures.

Doug

6 Likes

I think the similarity is only superficial. The property syntax is sort-of-similar if you ignore the colon, but different enough that it could actually introduce confusion as soon as you involve parameters in the non-shortened form. I guess it'd make sense if we could align the property syntax to the one in this proposal:

var x: Int {
  get: { currentPosition.x }
  set: { currentPosition.x = $0 }
}

However it's a bit late for such a change, I think.

1 Like

This feels like an apples-to-oranges comparison, IMO.

A computed property is a declaration, and the accessors inside it are also declarations. It makes sense for the curly braces to be used there, because it's consistent with the use of curly braces elsewhere for declarations in the language.

The proposed introduction of curly braces around multiple trailing closures may be syntactically similar but is semantically inconsistent with other usages in the language. Trailing closures are arguments to a function call, and if the user wants to surround them with a delimiter, there already exists such a delimiter in the language—parentheses. Introducing a second different delimiter feels harmful, whereas it feels more natural to extend the existing trailing closure syntax to support labels and multiple closures without the need for additional punctuation, especially since this has been argued above to even provide significant readability improvements and removal of grammatical ambiguity for the single trailing closure use case, which the brace approach cannot do as cleanly.

18 Likes

Wow, Thanks! It helps a lot! :slight_smile:

1 Like

Declarations, expressions, and statements are freely mixed within the bodies of closures and functions. Some places (type definitions and extensions) have curly braces containing only declarations. There's no firm rule we can invent here to describe the language as it is.

You're dismissing the analogy to computed properties, but they're the counterexample to the rule you're trying to establish. If you use the get-only shorthand, the curly braces mean the body of the function and have a mix of declarations, expressions, and statements. If you have more than one accessor, you wrap those up in another set of curly braces, and now those curly braces contain declarations. This is not new to Swift, and I've yet to see it as evidence of confusion. On the other hand, a var or subscript with an { always ends at the matching }. It's a really nice property, which admits quick scanning to find the beginning/end of the declaration, and helps with tools that mostly match up delimiters.

"Feels natural" is of subjective, which is fine---we're talking syntax. I fully understand the argument here about scaling the single-trailing-closure syntax to admit a label before the closure:

let numFoos = array.contains where: { $0.foo }

I see the appeal. We only had to add minimal information to our expression, just like it is a normal argument where you might or might not have a label.

However, I also see that this syntax is passing parameters using juxtaposition. That's not uncommon in functional languages with currying (where f a b means f(a)(b)), but outside of the narrow cases we havenow (between statements for semicolon elision, and with an immediate { for trailing closures), I think it would be a mistake to add more of it to the language. If you're going to add more cases where a b side-by-side might be one thing or might be two things, it should have a big win. Semicolon elision is Swift's one big case where we do this, and the scale of the improvement from semicolon elision is orders of magnitude higher than we're talking about here with removing the outer set of curly braces from the proposal.

But the alternative proposal still gets all of the problems of dealing with juxtaposition, which manifest as poor experiences with tools. For example, forgetting a case keyword in a switch is common:

switch x {
  case a: {
    doSomething()
  } 
  b: {
    doSomethingElse()
  } // error: no function named doSomething(b:)
}

That's fairly hard to diagnose well.

I find it really interesting how the formatting of examples using this alternative syntax has differed so widely from poster to poster. @Chris_Lattner3 used this style:

UIView.animate(withDuration: 0.7, delay: 1.0, options: .curveEaseOut) animations: {
    self.view.layoutIfNeeded()
  } completion: { finished in
    print("Basket doors opened!")
  }

because he's interested in reducing nesting, and perhaps to get a control-flow-like feel. Others use a full level of indentation:

Button()
        action: {
            doSomething()
        }
        label: {
            Text(text)
            SomeView()
                action: { doSomething() }
                label: { 
                    Text(text) 
                }
        }
    .buttonStyle(BorderlessButtonStyle())
    .padding(.bottom, 2)

If you're doing this last one, you really haven't saved yourself much by dropping those outer curly braces, because you're leaning very heavily on indentation for readability---which is not something we've previous had to depend on in Swift.

This last example is interesting because it's using SwiftUI modifiers, one of many fluent interfaces in the Swift world. That the modifiers are at a different indentation level from the { is really important---it's trying to highlight the fact that buttonStyle and padding aren't applying to that last closure, as one would normally expect from that structure of code, but to the call as a whole. A developer (and any tool that might try to help you indent this for readability) has to scan 12 lines up to find the less-indented line to which the modifier applies.

When I look at the little examples we rely so much on, like

let numFoos = array.contains where: { $0.foo }

I see the appeal of this alternative proposal. But as soon as we scale that up to a whole 14 lines, we start needing deep indentation as a crutch to understand our code. I consider that problem for the alternative proposal, and those "extraneous" curly braces don't feel so extraneous any more:

Button() {
    action: {
        doSomething()
    }
    label: {
        Text(text)
        SomeView() {
            action: { doSomething() }
            label: { 
                Text(text) 
            }
    }
}
.buttonStyle(BorderlessButtonStyle())
.padding(.bottom, 2)

Doug

15 Likes