Support use of an optional label for the first trailing closure

Proposed solution

This proposal would extend trailing closure syntax by supporting the use of an optional argument label for the first trailing closure.

(As with subsequent trailing closures, the user can write _ to indicate that the trailing closure should match an unlabeled parameter, but the need for such an explicit spelling should be rare.)

This solution allows Swift users to address each of the limitations enumerated above without abandoning trailing closure syntax:

  • Users can move existing callers into statement conditions without creating any parsing ambiguities:

    x = [1, 2, 3].first where: { $0 > 2 }
    if let x = x {
      /* ... */
    }
    
    if let x = [1, 2, 3].first where: { $0 > 2 } {
      /* ... */
    }
    
  • In the setting of a frequently evolving API, or where the "backwards scan" rule may lead to an unintuitive result, users can specify explicitly which parameter should match the trailing closure:

    func frobnicate(a: () -> Int = { 42 }, b: (() -> Int)? = nil) { /* ... */ }
    frobnicate b: { 21 }
    
  • Where the label provides meaningful information, users can preserve that information for the reader even when API authors have not reworked their APIs (or cannot do so) specifically for trailing closures:

    let x = [1, 2, 3, 2, 1].drop while: { $0 < 2 }
    
    frobnicate(x)
      excluding: { $0 < 2 }
      transform: { $0 * 2 }
    
  • Finally, in cases where multiple trailing closures are involved and one closure is not clearly "more primary," users can apply their judgment to label all trailing closure expressions:

    Binding
      get: { /* ... */ }
      set: { /* ... */ }
    
    store.scope
      state:  { $0.login }
      action: { AppAction.login($0) }
    // See: https://github.com/pointfreeco/swift-composable-architecture/
    
    viewStore.binding
      get:  { $0.name } 
      send: { Action.nameChanged($0) }
    // See: https://github.com/pointfreeco/swift-composable-architecture/
    
    let isSuccess = result.fold
      success: { _ in true }
      failure: { _ in false }
    

By making the use of a label optional for the first trailing closure, source compatibility is maintained with all existing code.

Objections

One possible objection to the syntax proposed is that users are unused to function names juxtaposed with argument labels without the use of parentheses. Indeed, SE-0279 claims: "Many find this spelling unsettling."

Certainly, this generalization will lead superficially to a new appearance for use sites such drop while: { /* ... */ }. However, the use of unparenthesized argument labels has already been made possible with SE-0279. There is no reason to conclude that, beyond any initial reactions to novelty, the labeling of the first trailing closure would be any more unsettling than the labeling of the second trailing closure using the same syntax and rules.

Another possible objection to the syntax proposed is that it will lead to a proliferation of different styles among users, leading to inconsistency and a corresponding proliferation of dicta among linters and style guides.

There are several reasons not to fear such an outcome:

First, as recounted above, closure expressions already offer a number of "optimizations" to users, who can choose to include or omit various information for the sake of clarity at the use site. Experience shows that users have used these optimizations generally to good rather than ill effect. This proposal demonstrates a number of scenarios where this additional feature—which we could consider another syntax "optimization"—could similarly be used to good effect.

Second, where linters and style guides have recommended against the use of trailing closures, it has often been for lack of clarity at the use site. For example, Google's style guide prohibits their use when a function call has multiple closure arguments so that each can be labeled. In other words, best practices have not coalesced towards blanket requirements to use or not to use some syntactic form, but rather to recommend use in cases where the resulting code is clear and non-use in cases where the result is unclear. Providing the opportunity for labels to be used for all trailing closures would obviate many current style guide recommendations against the use of trailing closures.

Third, although what's proposed here is an optional feature, consistency can be obtained (and users can be spared any indecision) by consistent first-party tooling behavior, which will permit alignment of declaration and use sites without source-breaking changes.

Tooling behavior

Swift's first-party code completion and formatting tools can make use of the feature proposed here to drive alignment of declaration and use sites without additional syntax or source-breaking changes.

When a set of behaviors is adopted consistently across Swift's first-party tools, API authors gain the ability to reason about readability at the use site, and API consumers are unburdened from having to make style choices (while still retaining the ability to improve the clarity of use sites without waiting on API authors—should they so choose).

(Click to expand or collapse this subsection.)
For illustrative purposes, one possible set of behaviors that would accomplish that objective is outlined.
  1. Where there is only one possible trailing closure, prefer an unlabeled closure if the parameter itself is unlabeled:

    // Preferred:
    let sum = measurements.reduce(0) { $0 + $1 }
    
    // Not preferred:
    let sum = measurements.reduce(0) _: { $0 + $1 }
    
  2. Where there is only one possible trailing closure, prefer a labeled closure if the parameter itself is labeled:

    // Preferred:
    words.sort by: { $0 > $1 }
    let x = numbers.drop while: { $0 < 2 }
    
    // Not preferred:
    words.sort { $0 > $1 }
    let x = numbers.drop { $0 < 2 }
    

    This might be controversial because it would diverge from currently written code (although that code would still remain valid). The intention is to work in conjunction with (1) to align declaration sites with use sites, giving API authors control over which arguments are labeled by default (albeit not mandatorily enforced by the compiler). Where the API consumer deems the result to be verbose and unhelpful, they can delete the label.

    (One alternative here is to adopt a heuristic, at least for methods known to the compiler. In SE-0118, a great number of parameter labels for closures were reworked, with many standardized to by when another word or phrase was not more apt. Therefore, we could treat parameters labeled by as though they were unlabeled—not the most elegant of long-term rules, however.)

  3. Where there are multiple possible trailing closures and at least one of the parameters is unlabeled, deem the last such parameter to be primary; prefer writing the call site such that the corresponding argument is the first and unlabeled trailing closure:

    func when<T>(
      _ condition: @autoclosure () -> Bool,
      _ then: () -> T,
      `else`: () -> T
    ) -> T { /* ... */ }
    
    // Preferred:
    when(2 < 3) {
      print("then")
    } else: {
      print("else")
    }
    
    // Not preferred:
    when(2 < 3)
      _: { print("then") }
      else: { print("else") }
    
  4. When there are multiple possible trailing closures, and none of the parameters are unlabeled, deem them to be equal in importance until such time as the API author reworks those labels; prefer writing the call site such that all of the corresponding arguments are trailing and labeled:

    // Preferred:
    Binding
      get: { ... }
      set: { ... }
    
    ipAddressPublisher.sink
      receiveCompletion: { ... }
      receiveValue: { ... }
    
    // Not preferred:
    Binding {
      ...
    } set: {
      ...
    }
    
    ipAddressPublisher.sink {
      ...
    } receiveValue: {
      ...
    }
    
  5. In the setting of statement conditions, where (1) and (3) aren't possible, prefer surrounding the entire caller with parentheses over abandoning trailing closure syntax.

45 Likes