SE-0279: Multiple Trailing Closures

As hopefully already implied in what I sad, the getting used to is a preference, not a requirement for everyone.


I'm not a compiler developer to be able to answer this particular question. And I think the question really is: Is the following technically possible?

func bar(_: Int, value: Int, _: Int) {}

// notice there are no more commas
bar(
  1
  value: 2
  3
)

If the answer is yes, then it should be possible to write the same, but with brackets. That again is a speculation from a non-compiler developer.

The above should answer the question, but I have to add that languages differ and you cannot read and understand every different language right away without learning some of its syntactical differences.

It might be confusing, but I don't think loop labels would be allowed for expressions such as if/else, switch, etc. and therefore this syntax should be unambiguous.

My whole point of the ideas that ignited in my brain through this proposal is that a feature like 'multi-line-trailing-parameter-list-closure' would allow us to write either the full parameter list or a trailing sub-set of it, but within brackets and with clearly defined rules.

And exactly this "writing a trailing subset of the parameter list in a visually different closure-like from" is what being proposed in this thread, not just "multiple trailing closures", which is too strict and not flexible in its usage.

But I can expect that there are not vastly different representations for the same constructs that require heavy visual bookkeeping and backtracking in what you already parsed to come to a conclusion of what you see. Sure, for a parser that's fine… for a human not so much. (The example here being that I have to spot the comma after the UILabel to retroactively know that I wasn't looking at at trailing closure with a function builder but something else.)

5 Likes

For compiler, probably not, but for human, it can gets tricky with multiline expressions.

bar(
  1
  value: 2 +
    1 // huh?
  3
)

Sure, I don't disagree with you on this example. We can already have that situation in some function builder context as well though (if we'd ignore the label from the example).

// imagine some SwiftUI in here
buildView {
  Text("1")
  /* label: */ Text("2") + 
    Text("1")
  Text("3")
}

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?