SE-0279: Multiple Trailing Closures

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

Hey Brent,

If it were a small surgical change with little to no side effects fair, but removing ‘(‘ and commas seems not to be worth this long review discussion nor the implementation efforts.

When we lost argument labels in stored callbacks, which actually goes against a core tenet of Swift around clarity at call site, there was comparatively not as long a discussion (it was long and heated, but I believe the problem there is worse than this honestly).

Kind Regards,

Goffredo

3 Likes

I think both Combine and SwiftUI, and the changes in Swift powering them, are nothing short of mind blowing for the Apple platforms, but there is a point coming perhaps soon where Apple will need to take a direct stance on Swift being community led/driven or just open source and state it outright.
SwiftUI, and the WWDC docs and demos based on it, necessitated Swift language changes that were kept quiet until after WWDC and then had to be merged in without many huge breaking changes not to waste all the effort going in the iOS 13 betas and the WWDC docs and tutorials. Swift project being steered by Apple meant that those changes would go in, luckily they were good changes but that is not the point.

A similar thing goes for its multiplatform ambitions: how many engineers and money do they invest vs. how much they expect OSS magic to do its thing and the community to pick it up and push it.

2 Likes

I'm sorry but this doesn't look like an improvement to me. The before is super clear already; it's a call to a function that takes two arguments that you happened to pass as closures. I can tell by the ( and ). The "after" isn't as clear. The after looks like sink takes a single closure, but then you realize it doesn't because inside the trailing closure there are two labels and two nested closures. Except they're not really closures nested in a closure, they're function arguments for sink that are written between {} instead of ().

16 Likes