SE-0279: Multiple Trailing Closures

The issue with {} that you don't have with () is that the former is already in use for closures. If what you put in your {} can be parsed both as a closure and an argument list, then the syntax is ambiguous.

Consider this:

func foo<T>(_ arg: T) {}

foo {
   bar()
}

If the braces represent an argument list, then it's equivalent to foo(bar()). But this is also a valid trailing closure in today's Swift and it means foo({ bar() }). It can't be both, obviously.

For the thing to be unambiguous, it needs to have at least one argument label. Then the parser could start as usual, assuming it's a trailing closure, and then backtrack and reinterpret everything as an argument list on the first label it sees (that isn't followed by a loop keyword). That seems like a recipe for confusing diagnostic messages however: if reinterpreting as an argument list fails too, it won't be able to tell you whether your argument list is malformed or whether you have an incorrect statement in your closure.

Honestly, I'm pretty sure we can't have label-less parameters appearing in a brace-delimited parameter list.


It can made unambiguous for the parser by looking at the keyword that follows. But for the human eye I think it'll be extremely confusing if this:

foo {
   label: while true { ... }
}

is a trailing closure as in

foo({ label: while true { ... } })

while this:

foo {
   label: if true { ... } else { ... }
}

is an argument list as in

foo(label: if true { ... } else { ... })

Obviously, that's only a problem if we want to pursue the idea of making if/else an expression.

5 Likes

After almost 200 replies in less than a week, I have yet to see a compelling argument in favor of the proposal as is - or for an alternative.

I'm a strong -1 on this because it's introducing a new syntax that does not replace or objectively provide new ways for writing more correct code.

While I don't think this would be a fundamental shift in the language if it were to be accepted - such as if Swift changed to allow implicit conversions - as it's just syntax sugar.

However, I would immediately be looking for a linter to enforce not allowing use of this form, contradicting the motivating arguments in favor of this proposal. It will go down in the category of endless arguments of personal preference that we already have regarding coding styles.

Even with @DevAndArtist's proposal of expanding the syntax to allow all parameters be expressed as the proposed syntax, that makes it worse in my opinion - even if it's a bit more aesthetically pleasing or easier to read.

My concerns with this:

  1. It is not replacing existing syntax, it's adding yet another way of writing closures that individual teams / projects will determine what they like best and using a linter to enforce it.
  2. This is an objectively worse change to Swift, when comparing precedent to previous proposals and discussions on both syntax changes and additions to the Standard Library.
    • In quite literally many cases, it is just changing ( to {
    • It does not make writing correct code easier, compared to what is already possible today.
    • It does not make reading code easier, as there will be new noise and a new way of entirely writing that developers who aren't following Swift Evolution will be caught off guard by. There will be dozens of StackOverflow questions of "What is going on in this closure? I thought it was written as ..."
  3. My understanding of the original intent behind trailing closures was that it was syntax sugar for a single closure argument. Just as using the $0...$n syntax gets unwieldy after perhaps 3 arguments, this is all developer preference on when to use one syntax over the other.
21 Likes

That's some good feedback, I really appreciate that observation, thanks.

I'm pretty sure any form we came up with will be labeled, and use _: for unlabeled items.

Also, I think that all-or-nothing can be overbearing. I think we should let the caller decide how many of the trailing arguments should be line-separated, rather than comma-separated. It echos my idea above.

Another idea, could 'currying at call side' solve the whole debacle?

The example from the proposed solution section becomes:

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

We're no longer using brackets anymore and commas are still required.

I agree with Doug here. This is a weird hole in the language that really should be filled if we can. That said, I think this specific proposal doesn't solve the problem. Its net result is to trade punctuation and eliminate one comma, which is not a win in my opinion.

I think that Rob captured this really well:

I was trying to avoid getting into counterproposals in this thread, because that is not how swift-evolution proposal reviews are supposed to work. However, given this thread is already huge...

I'd start with the goals:

  1. We need to support the multiple trailing closure case.
  2. We need to make it consistent with the single trailing closure case.
  3. To Rob's point above, it should reduce nesting, which is the main payoff of the existing trailing closure syntax.
  4. While we're at it, we should look at the other weird behavior of trailing closure syntax, the fact that it eats the keyword argument for the closure.

I'd also observe that trailing closure syntax is a specifically targeted way of popping one argument out of a call. It is specific to closures, but there is no reason to think that SwiftUI-like DSLs would want this only for closures: there are lots of nesting structures that could be better to flatten.

Given this pile of goals, I'd recommend pursuing a more generic approach of allowing arbitrary arguments to be punched out from calls. I'll describe a general version of this, but it might make sense to add a provision (e.g. an op-in attribute) to allow this behavior to be controlled/limited on the funcdecl side. If we were going to make this general, we would be making things like this be synonyms (listed in order of increasing craziness and impact):

   foo(bar: 1,   baz: Button())
   foo(bar: 1)   baz: Button()
   foo() bar: 1, baz: Button()
   foo   bar: 1, baz: Button()
   foo   bar: 1  baz: Button()

This would definitely be ambiguous in recursive cases, so it might make sense to limit the subexpressions allows to specific expression productions like literals, parans, braces, etc that are bounded. We can use a whitespace role or something else to support this, as we do in many other places.

With this approach, the example from the proposal would go from being this:

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

to being this:

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

Which could be alternatively formatted as:

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

Note that it eliminated the outer level of curlies, which is the main win of the existing trailing closure syntax we have.

If we had this, then we could also apply this to the existing trailing closure syntax, allowing the use of the keyword argument to disambiguate cases like this:

func foo(fp1: () -> () ) {}
func foo(fp2: () -> () ) {}

foo(fp1: {})       // ok
foo {}             // ambiguous
foo fp1: {}        // ok

Overall, to reiterate my review above, I am -1 on this proposal, but +1 on the goal of improving this aspect of the language. I request that we do so with an eye towards generality and making the language more consistent, rather than introducing a weird special case just to eliminate a single comma.

-Chris

29 Likes

Please tell me if I understand you correctly. In case of this example:

func foo(
  _ closure_1: () -> Void, 
  _ closure_2: () -> Void, 
  flag: Bool, 
  _: Int
) { ... }

We'd have all possible separation forms such as the following:

let boolean = true

foo({ print(1) }, { print(2) }) {
  flag: boolean,
  42
}

foo({ print(1) }) {
  { print(2) },
  flag: boolean,
  42
}

// `foo() { ... }` would also be allowed,
// but we can omit `()`
foo {
  { 
    print(1) 
  },
  { print(2) },
  flag: boolean,
  42
}

There can't be a trailing closure that represents a single parameter that is not a true trailing closure, because of the issues mentioned here by @michelf.

It always need label, so it'd be


foo {
  _: { 
    print(1) 
  }
  _: { print(2) }
  flag: boolean
  _: 42
}

which I think the same modification could apply to your ideas.

And yes, we'd have all the separations.

3 Likes

Why would we always require a label if we had commas to separate the parameters?

It is newline-separated inside {}, and comma-separated inside (). That's why label is always needed inside {}.

So if I understand correctly, this now goes into the direction of a completely new syntax of specifying arguments to a function and has nothing to do anymore with closures or even "trailing".

1 Like

Hmm the _: for label-less parameters seems like an overall regression if you visually compare these two forms:

foo({ print(1) }) {
  _: { print(2) }
  flag: boolean
  _: 42
}

foo {
  _: { 
    print(1) 
  }
  _: { print(2) }
  flag: boolean
  _: 42
}
foo({ print(1) }) {
  { print(2) },
  flag: boolean,
  42
}


foo {
  { 
    print(1) 
  },
  { print(2) },
  flag: boolean,
  42
}

This is of course just my opinion.

It is indeed a regression. The problem is that a list can not work without any delimiter. So either comma or label needs to be there, and I think _: would be less common, as it is Swifty to name all parameters anyway.

2 Likes

Just thinking out loud yet another different idea: I wonder if we could introduce a slightly different from of trailing closures? ~{ } or ~{ }~ for example? The rules there can be defined as wish as this would be completely new.

foo({ print(1) }) ~{
  { print(2) }
  flag: boolean
  42
}

foo ~{
  { 
    print(1) 
  }
  { print(2) }
  flag: boolean
  42
}

But again I can see people argue that this would be such a hard to notice thing or something in that direction. :thinking:


Anyways I think I provided enough personal feedback. Let's see how the core team decides on the proposal.

Or maybe a newline?

This is where I get stuck. Anything we do is going to introduce a radical new way of calling functions - making Swift code radically harder to read or learn. How much are we willing to sacrifice to support this goal? How much do we need to gain to make it worth it?

Fundamentally, it isn’t possible to remove nesting unless you use coroutines/async await or similar continuation-like transforms.

All these other syntax ideas can do is hide nesting. That means it’s a fundamental requirement to hide that an argument is part of a call. Again, that makes things much harder to reason about. What was the original motivation for trailing closures? Wasn’t it to support simple functional APIs?

Superficially, this looks like a simple extension of existing syntax, but I think it’s really for entirely different use-cases. We need to think hard about if it’s really worth making the language so much more complex to support this.

I’m seeing more and more users on forums like reddit and stackoverflow having trouble with our existing syntax. I’m not sure if you’re aware, but swift is gaining a reputation for being a grab-bag of arbitrary syntax rules. That’s why I’m so concerned about making massive changes like this.

25 Likes

I'd preferred S-expressions.

1 Like

Incidentally, during a bit of weekend hacking, I happen to have written some code where I would have used this if I could have. It uses Combine to do reverse DNS lookups on all of my IP addresses in parallel:

      resolutions = Publishers.MergeMany(
        try Address.byInterface().map { (interface, addr) in
          addr.resolveName()
          .map { hostname in
            Identity(interface: interface, address: addr, hostname: hostname)
          }
          .catch { error in
            // ...eliding a whole bunch of junk here...
          }
        }
      )
      .filter {
        $0.hostname != nil
      }
      .sink(
        receiveCompletion: { completion in
          // ...elided...
        },
        receiveValue: { identity in
          self.hostnames.insert(identity.hostname!)
        }
      )

Specifically, if we had this feature, I would have used it in that final sink(receiveCompletion:receiveValue:) call:

      resolutions = Publishers.MergeMany(
        try Address.byInterface().map { (interface, addr) in
          addr.resolveName()
          .map { hostname in
            Identity(interface: interface, address: addr, hostname: hostname)
          }
          .catch { error in
            // ...eliding a whole bunch of junk here...
          }
        }
      )
      .filter {
        $0.hostname != nil
      }
      .sink {
        receiveCompletion: { completion in
          // ...elided...
        }
        receiveValue: { identity in
          self.hostnames.insert(identity.hostname!)
        }
      }

Small improvement? Sure. But it would make sink(receiveCompletion:receiveValue:) feel natural to use, rather than being a pretty awkward fit compared to the rest of Combine. And maybe I'm just a Combine noob, but when I'm working in Combine, I sure do seem to end up calling sink(receiveCompletion:receiveValue:) an awful lot.

-1.

I've read through the thread and I agree with those who've stated that this is something that could be improved but that this proposal doesn't do it. It merely changes the enclosing delimiter and eliminates the commas, which is such a bare improvement it isn't worth the additional language complexity.

I don't know if this proposal was driven by Apple's needs or not, but they could easily offer a significant improvement to the UX around multiple closures just by including a proper code formatter in Xcode. Hopefully Xcode 12 has LSP support and can use swift-format or other tools natively. Just doing that would improve the developer experience far more than this proposal, which would need Xcode formatter support anyway.

20 Likes

-1.

The animation example in the proposal is probably the only one I can comment on since I don't use SwiftUI that much, but I don't see why one would try to use a trailing closure when:

  • there are multiple arguments that take a closure
  • such a call is already nested in a closure, trailing or otherwise

Consistently formatting all the arguments yields

func toggle() {
  UIView.animate(
    withDuration: 1,
    animations: {
      self.myView.backgroundColor = UIColor.green
      self.myView.frame.size.width += 50
      self.myView.frame.size.height += 20
      self.myView.center.x += 20
    },
    completion: { _ in
      UIView.animate(
        withDuration: 1,
        delay: 0.25,
        options: [.autoreverse, .repeat],
        animations: {
          self.myView.frame.origin.y -= 20
        }
      )
    }
  )
}

and in my opinion this is perfectly readable. I fail to see how trying to shoehorn trailing closures into long complicated code like this improves anything. Trailing closures, in my view, are a tool best used in simple calls to make them a bit nicer.

I'd also like to echo others in this thread, that I don't believe this proposal would get to review without being pushed by Apple. If this is indeed the case, it should be stated upfront. Not doing so risks turning people off from contributing in the future.

EDIT: to expand a bit on the above, this proposal seems to change so little, yet it has the potential to divide the community over the spelling of such calls. Swift is supposedly an opinionated language (though I couldn't find this phrase when searching swift.org), yet this just adds another spelling to closure arguments. Let's not turn this language into C++.

12 Likes