[Pitch] Explicit syntax for trailing closure matching

Motivation:

As advertised, SE-0286 Forward scan matching for trailing closures (source breaking) introduces breaking changes to a minor dot-release.

We've found that this breaks our app based on ReactiveSwift, and we're probably not alone.

Fortunately we were able to get our app to compile by simply adding explicit type information to a handful of variables, but still, we were left wondering if perhaps multiple trailing closure syntax (a form of syntactic sugar) is really worth breaking changes in a minor dot release.

Especially we wondered this after we noticed a place where the compiler gave a warning stating that "backwards scanning is deprecated" and recommending that we stop using trailing closure syntax in a few places altogether!

Notably where the compiler evidently no longer wants to support a trailing closure would be a place like:

typealias Voidy = () -> Void
func foo(interim: Voidy = nil, completion: Voidy? = nil) {
    //dostuff
}

foo { completion() }

This still runs but now we're suggested not to use trailing closures at all, since the compiler has no way to infer which of the two trailing closures is meant to be represented by the trailer in this example.

Not use a trailing closure at all seems like a step backwards here, though.

Pitch:

I might suggest we add some new explicit trailing closure syntax that would allow us to keep using trailing closures in any situation and hopefully prevent the need to introduce breaking changes in a dot-release (which goes against the purpose of semantic versioning):

foo { completion() } // normal: use in last closure position (same as 5.2 behavior; default until Swift 6)
foo -{ completion() } // single-hyphen: same as normal behavior (explicit last)
foo --{completion() } // double-hyphen: use in second-to-last available closure position
foo ---{completion() } // triple-hyphen: use in third-to-last available closure position
foo +{ completion() } // single-plus: use in first available closure position
foo ++{ completion() } // double-plus: use in second available closure position
foo +++{ completion() } // triple-plus: use in third available closure position
foo *{ completion() } // use the same close *for every available closure argument matching its signature*

Adopting this opt-in syntax would mean we don't need to break any source code in Swift 5.3, and would make the transition to 6 a bit smoother by not requiring people to stop using trailing closure syntax altogether in places where they are only using the final trailing closure.

For example given this function signature:

func foo(num: Int, progress: ((Int) -> Void)? = nil, delta: ((Int) -> Void)? = nil, success: ((Int) -> Void)? = nil, failure: ((Int) -> Void)? = nil) -> Int

With the explicit syntax you could do any of the following:

let num = foo(42) *{ print("num: \($0)") }

let num = foo(42) 
    --{ print("success: \($0)") } 
    +{ print("progress: \($0)") }

let num = foo(42) 
    --{ print("success: \($0)") } 
    -{ print("failure: \($0)") }

let num = foo(42) 
    +{ print("progress: \($0)") }
    --{ print("success: \($0)") } 
    -{ print("failure: \($0)") }


let num = foo(42) 
    +{ print("progress: \($0)") }
    -{ print("failure: \($0)") }
    --{ print("success: \($0)") } 

let num = foo(42) 
    +{ print("progress: \($0)") }
    ++{ print("delta: \($0)") }
    --{ print("success: \($0)") } 
    -{ print("failure: \($0)") }

let num = foo(42) 
    ++{ print("delta: \($0)") }
    --{ print("success: \($0)") } 

let num = foo(42) 
    ++{ print("delta: \($0)") }
    +++{ print("success: \($0)") } 

Compared with the new compiler warning's recommendation to stop using trailing closure syntax at all in many of these cases, it seems to me that the main benefits of this pitch would be:

  • lets us avoid breaking changes in a dot release
  • gives a way to keep using trailing closure syntax with scan behaviors other than forwards
  • that means, less nesting than going back to not using trailing closures at all
  • it's fully optional/opt-in, so no real downsides here
  • possibly it gives an alternative to the requirement of using floating argument labels if you don't want to use them

(By "floating argument labels" I mean, those ones that hang outside of parentheses in the current form of multiple trailing closure syntax, and that seem to be required in some cases. Some folks might not be a huge fan of this look.)

Etc. etc.

When Swift 6 drops the implicit behavior could change without breaking any code where people had adopted the explicit format. And 5.3 could also avoid breaking any code too.

Thoughts?

Alternatives Considered:

I don't have my heart set on - and + in particular—so if anyone feels they look too much like git diff marks, I'd love to hear other suggestions.

I also do not particularly care whether these explicit symbols would go onto the closing brace or the opening brace—in case some folks would worry about changing the indentation position of {, we could do this instead:

let num = foo(42) 
    { print("progress: \($0)") }+
    { print("delta: \($0)") }++

In addition to this pitch (or instead of it), we might also consider to add a per-function-call (or per-function-declaration) explicit symbol that would specify whether the compiler should use backwards or forwards scanning for trailing closures for that specific call:

@backwardsScanning
func myFunc(_ a: (() -> Void)?, _ b: (() -> Void)?, _ c: (() -> Void)?)

// or 

myFunc > { foo() } { bar() } { baz() } // forwards scan
myFunc < { foo() } { bar() } { baz() } // backwards scan

Disclaimer:

I did try to see if this was already suggested but got no hits searching for "explicit" on the threads where trailing closure syntax was discussed.

Also, I realize of course that the whole point of trailing closure syntax is to avoid having to use explicit marks to signify which closures are associated with which function parameters—so I would anticipate this solution might be seen as less optimal than just going back to using parentheses and commas.

However I don't see any drawbacks to having more explicit syntax if it lets us avoid the extra nesting that we're on track to incur otherwise.

1 Like

No source break was intended for Swift 5.3, and any such that you find is a bug that should be reported. It is not part of the proposed design to break any code in a minor release.

The explicit syntax we have discussed at length is the addition of optional labels for the first trailing closure. The core team has discussed as follows:

Core Team: SE-0286 has changed the basis against which this proposal was written. At this point, we need more experience with the combination of SE-0279 and SE-0286 before considering additional changes to the interpretation of trailing closures.

The core team has decided, therefore, that further proposals on this topic will not be considered at this point.

4 Likes

I for one, find this to be hard to read and littered with noise. Although I am sympathetic to the goal.

2 Likes