SE-0279: Multiple Trailing Closures

Just curiosity, how ambiguous could be?
Already type can be nested which itself declare own functions as same name.

Consider this:

func foo(closure: () -> Void) {
    // ...
}

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

bar {
    foo {
        // Is this foo the function or foo the parameter?
    }
}

This wouldn't work for a couple reasons:

  • It doesn't seem like it would work with multiple closures, which is the motivation for this proposal.
  • The syntax identifier.contains(where:) is already used to reference a method by its full name, so I don't think it could also be used to call it in this manner.

The example I gave used a single closure not simply to replace that syntax, but to show how the alternatively proposed syntax that doesn't use braces for multiple closures also works well to re-add context that is lost when using trailing closure syntax in today's single-closure functions.

Oh.. I see. I understand that.
If can be, Might bar {} consider as new namespace like type declaration, prioritize bar's foo first possible? Just if it is possible choice...

Fair point about the multiple closures extensibility, but just to be clear - that syntax isn’t an addition. You can write that in the language today and it invokes the contains method with the supplied trailing closure.

2 Likes

Good point! I had overlooked that, and if more arguments were added before the label, it would still be fine.

Still, keeping the parentheses but moving the closure outside of them doesn't seem like an improvement either. Why would someone choose to write array.contains(where:) { ... } over array.contains(where: { ... })? That only moves a single parenthesis, but is less readable.

1 Like

I'm not an expert on Swift's parser, but I believe this type of prioritization is pretty inefficient and could slow the parser down. I've seen similar ideas shot down for this reason. You basically want to be able to parse purely syntactically. The ":"s in the labels allows the parser to figure everything out with just syntax.

It also just naturally leads to confusing diagnostics in cases where something you didn't expect shows up in the lookup. And source compatibility would require us to disambiguate in favor of calls, not argument labels.

Swift is designed so that you can parse it correctly without having to resolve any overloads; that's really good for a lot of tools like linters and syntax highlighters, and it basically makes SwiftSyntax possible. The behavior you're talking about would probably break that property.

(But thank you for asking this question—not everyone realizes that Swift is designed this way. I'm not sure if it's even written down anywhere.)

3 Likes

Thanks for replying.
I understand that. And that is awesome design.

But, I have just another question that is this different case than that syntax?

func test() {
    print("Call on global function")
}

class A {
    func test() {
        print("Call on class function")
    }
    func invoke() { test() }
}

A().invoke()
// Result: Call on class function
// Is this ambiguous enough too?
A miniature compiler layering lesson, hidden because it's drifting away from the topic.

What's different is that the parse of this example is not ambiguous, because a parse is not expected to be detailed enough to distinguish between the two possible test()s.

To explain what I mean, let's look at an AST dump for your code snippet. (The AST is the tree the compiler parses your code into.) The dump is pretty big, so I'll cut out everything but the call to test():

$ pbpaste | swift -Xfrontend -dump-parse -
[snip]
      (call_expr type='<null>' arg_labels=
        (unresolved_decl_ref_expr type='<null>' name=test function_ref=unapplied)
        (tuple_expr type='()' location=<stdin>:9:25 range=[<stdin>:9:25 - line:9:26]))))
[snip]

After parsing, the test() call is represented as simply a call to an "Unresolved Declaration Reference Expression" (unresolved_decl_ref_expr) for a declaration named test. The parser has not even attempted to determine what test refers to, but that's fine—that's not the parser's job.

Once parsing has completed, there's a separate step—"semantic analysis", abbreviated to "Sema" by the Swift compiler—which, among other things, looks up names, selects overloads, and type-checks your code. Here's what the same AST looks like after Sema:

$ pbpaste | swift -Xfrontend -dump-ast -
[snip]
      (call_expr type='()' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:26] nothrow arg_labels=
        (dot_syntax_call_expr implicit type='() -> ()' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] nothrow
          (declref_expr type='(A) -> () -> ()' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] decl=main.(file).A.test()@<stdin>:6:10 function_ref=single)
          (declref_expr implicit type='A' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] decl=main.(file).A.invoke().self@<stdin>:9:10 function_ref=unapplied))
        (tuple_expr type='()' location=<stdin>:9:25 range=[<stdin>:9:25 - line:9:26])))
[snip]

Wow, that's a lot more stuff! Sema has looked at our unresolved_decl_ref_expr for test and determined that it refers to the instance method it calls main.(file).A.test()@<stdin>:6:10. It has also determined that it needs to implicitly reference self here and turn test into self.test. And it has inferred and filled in types all throughout the AST.

But notice that, even though Sema changed a lot of things, it didn't fundamentally reshape the AST. It just replaced this:

        (unresolved_decl_ref_expr type='<null>' name=test function_ref=unapplied)

With this:

        (dot_syntax_call_expr implicit type='() -> ()' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] nothrow
          (declref_expr type='(A) -> () -> ()' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] decl=main.(file).A.test()@<stdin>:6:10 function_ref=single)
          (declref_expr implicit type='A' location=<stdin>:9:21 range=[<stdin>:9:21 - line:9:21] decl=main.(file).A.invoke().self@<stdin>:9:10 function_ref=unapplied))

Which is basically the same thing but with more detail. That's how Sema works—it takes the bare-bones AST produced by the parser and replaces bits of it with more detailed versions until it has figured out what everything we parsed actually means.

The difference between this case and your proposed feature is that, in your trailing closure design, Sema would not be replacing parts of the AST with more detailed ones—it would be reinterpreting parts of the AST and replacing them with something different. The parser would say "this is a trailing closure containing a bunch of calls to functions with trailing closures", but then Sema would decide "no, these are multiple trailing closures passed to a single call". It's not technically impossible for Sema to make a substitution this radical, but it would probably create some difficulties, and it would mean that parse-only tools couldn't tell what was and wasn't a closure. We really don't want that, so we're looking for a design where the parser can decide, all on its own, what is and what isn't an argument label.

I hope this was helpful!

14 Likes

I'm -1 on the proposal as written. I don't believe Swift needs "Yet Another Function Calling Syntax". I'm more neutral on @xwu's alternative syntax, assuming it can be fully normalized with the existing syntax

But I want to make a small meta-complaint about one of the motivating examples in the proposal:

Button(action: {
   ...
}) {
   Text("Hello!")
}

Having played with SwiftUI extensively when it was released (and then ultimately mostly given up and gone back to UIKit/AppKit), I want to point out that IMHO, the biggest problem with the above syntax is that one of the closures is Swift, and the other one is not Swift... That is, it is a DSL syntax with stricter syntactic rules than the Swift programming language.

In my opinion, if that isn't somehow resolved, then this change (making the two closures look more similar) will actually aggravate the problem above, making the code even harder to read and write correctly.

5 Likes

Yes, I think this is a very reasonable sweet spot.

6 Likes

Jumping back to how this could improve the single trailing closure use case as well, I thought of another small but possible advantage of the alternative form that uses no braces around the trailing closures but keeps the label.

Today, we have an awkward parse ambiguity if you try to use a trailing closure in a conditional statement like if/guard/while:

// not valid
if array.contains { $0.foo } { ... }

// instead, you have to do either of these
if (array.contains { $0.foo }) { ... }
if array.contains(where: { $0.foo }) { ... }

The problem, if I understand it correctly, is that when the parser hits the first {, it doesn't have any semantic information about whether the function being called takes a trailing closure or not. Instead of trying guess which one the user intends, the parser throws up an ambiguity error, because this could be interpreted two different ways:

if array.contains { $0.foo } { ... }
         ^        ^          ^_ some other block expression
         |         |_ the true block
         |_ a property

if array.contains { $0.foo } { ... }
         ^        ^          ^_ the true block
         |         |_ ...and its trailing closure
         |_ a function call...

However, if we could wedge a label before the trailing closure, that would resolve the ambiguity, right? The presence of label and colon tokens would make it unambiguous that this is definitely a function call with a trailing closure:

if array.contains where: { $0.foo } { ... }

This is a small detail, but it's important when you consider things like auto-formatters and other syntax tooling which today cannot uniformly apply style rules requiring the use of trailing closures because of holes in the language like this.

I'm not sure if the brace form in the proposal could solve this as easily, since it would have to look ahead quite a bit to determine whether the label and colon were inside the braces. Even if it was unambiguous, it would look pretty awful because of the sheer number of curly braces so close together, so it's no improvement over just using parentheses:

if array.contains { where: { $0.foo } } { ... }
9 Likes

This is an excellent point. Not losing any possibility of using trailing closure syntax in these circumstances is an attractive bonus.

The other circumstance where we have a win is that we create a way for disambiguation of overloads.

2 Likes

Yes! That's another issue that prevents safe automated syntactic transformations of function calls into their trailing closure form.

1 Like

I've been following the thread closely and agree that this seems to be a sweet spot.

4 Likes

This may be the second best option, but I like putting the label before the closure better. I think it helps with code folding and allows the bracket to start on the next line without looking strange. The label would also look like a label for y.

// This might be confusing.  `foo` would look like a label for `y` to beginners and
// code folding looks nicer with brackets.
foobar {
    foo: y in
        print("\(x)") 
    bar: x in
        print("\(x)") 
}

// Nice code folding.  No confusion what label is representing.
foobar
    foo: { x in
        print("\(x)") 
    }
    bar: { y in
        print("\(y)") 
    }

This is the alternative that we are talking about.

1 Like

What a thread!

What is your evaluation of the proposal?

I'm joining the -1 club, both as a developer and as a teacher.

If I wear my developer's hat, I'm relatively neutral on this proposal. I think it just swaps parenthesis for braces. Some people might think it looks nicer, I don't, and I would probably never use the new syntax.

But to be fair, I have to admit that I don't find trailing closures particularly pretty anyway, specifically because they eat the parameter's label. For instance, imho vec.drop(while: { ... }) is much more readable and self-explanatory than vec.drop { ... }. That's why I could get on board more easily with something like what @allevato suggested.

Now if I wear my teacher's hat, that's a solid -1. One recurring complaint about Swift is that it has a lot of syntax, and it does. While the language is pretty clean once one gets used to, it remains an obstacle. Believe it or not, it isn't obvious for students to understand that all these expressions are in fact equivalent:

vec.reduce(0, +)
vec.reduce(0, { $0 + $1 })
vec.reduce(0) { $0 + $1 }
vec.reduce(0) { a, b in a + b }

This proposal would add yet another syntactic alternative, without any significant benefit imho (see my first point, with my developer's hat). In fact, I would argue that this would be detrimental to Swift, making it seem more cryptic to the untrained eye. While an experienced developer can just abstract away various flavors of sugar according to her own preference, learning developers usually relies on syntax to understand programming before they can reason in terms of semantics. Adding countless alternatives hinders this process.

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

I do not believe so. I agree with those who said it was mostly a formatting issue. But as I mentioned earlier, I'm not the biggest fan of trailing closures anyway.

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

I don't think so. While this does look like subscripts or property getters/setters/observers, it does not feel like a function call and more like a configuration file in some external DSL.

Now I understand that syntax is a pretty subjective topic, so what I personally find "swifty" might seem horrendous to the next person in line.

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

Nothing particular in mind.

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

I carefully read the proposal and all the discussion in this thread.

14 Likes