Support use of an optional label for the first trailing closure

The core team has asked that discussion be spawned in a separate thread regarding support for use of an optional label for the first trailing closure. Therefore, later today, I'll be sharing a draft proposal here which addresses that topic.

While it has been brought to mind by discussions on multiple trailing closures, the issue is entirely a separable one. Shortcomings related to our unlabeled trailing closure syntax have been known for years.

Readers will note that the draft proposal is amply and exclusively motivated by those shortcomings that have always applied to single trailing closures, with which we already have extensive experience requiring no further period of trial.

I hope that as a community we can take this opportunity as a fresh start to engage in a process that will build each other up while pushing the language forward. For that reason, I'm starting off the thread with these prefatory remarks in advance of the draft proposal text.

72 Likes
Pre-pitch discussion

I think we need, not only strong motivation, but also strong migration story, if the end result would be source incompatible.

Pre-pitch discussion on a possible migration solution

I assume the migration is focused more on source stability than everything else. If this is the case, one solution I have in mind is overloading existing functions like filter(where:) with filter(_:), and provide warnings for and immediately mark as deprecated the latter.

2 Likes
Pre-pitch discussion on preference for mandatory first labels

Since this is now separate from SE-0279, I think we should directly propose for mandatory first labels. We can have optional first labels as the first step in the migration story, as part of the proposal.

2 Likes
Discussion

That doesn't seem necessary. Like capture labels, the closure label itself should always be optional unless the user wants it. If we want Swift's default style to include the label, that's fine, the community will just continue to use formatters to standardize.

4 Likes
Summary

When you say "unless the user wants it" are you referring to the API author or consumer? It seems to me that if there is a choice, it should only be available to the author. It's their responsibility to form a well designed API.

2 Likes
Discussion

There's currently no mechanism for author control over such a feature, and it's not part of the proposal, so it's moot at the moment.

request to wait for the proposal

I suggest we wait and let @xwu post his proposal before we start discussing. It may address some of the points people are bringing up.

2 Likes
Semi-related discussion about formatting/syntax representation

To make sure I'm not misunderstanding the request here: If what you mean here is that a formatter should take a function call that does not have the label and transform it to insert the label, that's infeasible. Formatters like swift-format use syntax-tree representations that stop at the parse stage, so they don't contain any type/semantic information. For a given function call with a trailing closure that lacks a label, it has no way to know what that label should be.

The formatter also can't use semantic information because doing so would require successfully compiling all dependencies before formatting, which is too burdensome a requirement for a code formatter.

Summary

I guess I was including linters as well here. Though I would hope that formatters would be moving more toward a richer parsing representation (where's SwiftSyntax?) that could preserve this information.

Please don't speculate until the actual proposal was posted!

I strongly second this. Guys calm down and please stay away from this thread until the draft proposal was published. Itā€˜s worth nothing to jump on a speculation train right now, just a waste of everyones time.

2 Likes
Related discussion (reply to @allevato)

Going in the other direction, though, which I think was what @Jon_Shier has mentioned above, is imminently feasible!

I will post the proposal text in short order.

Support use of an optional label for the first trailing closure

Introduction

This proposal extends trailing closure syntax by supporting the use of an optional argument label for the first trailing closure. When paired with changes in tooling behavior, this feature would promote the alignment of declaration and use sites without source-breaking changes.

Motivation

Closure expressions in Swift have a lightweight syntax with several "optimizations" as described in The Swift Programming Language:

  • Inferring parameter and return value types from context
  • Implicit returns from single-expression closures
  • Shorthand argument names
  • Trailing closure syntax

These optimizations allow users to write out only the most salient details explicitly at the point of use, promoting clarity when used judiciously. A caller can take advantage of some or all of these optimizations in a variety of combinations. For instance, one may use trailing closure syntax while explicitly naming the return type.

In Swift, function arguments can have labels prescribed by the author of the function that clarify the role of that argument. For instance, the mathematical function known as atan2 in C/C++ is named atan2(y:x:) in Swift so as to emphasize the somewhat unintuitive order in which arguments must be passed to that function. However, in the case of trailing closure syntax, the first trailing closure is always written without any argument label. For example:

// Without trailing closure syntax:
[1, 2, 3].first(where: { $0 > 2 })

// With trailing closure syntax:
[1, 2, 3].first { $0 > 2 }

This syntax presents at least three significant limitations which have been present in every shipping version of Swift, which we'll explore in turn. In each of these cases, the present workaround is to abandon the use of trailing closure syntax. Indeed, the authors of Google's style guide describe almost exactly the same limitations and prohibit the use of trailing closure syntax under those circumstances. Unfortunately, users cannot then avail themselves of all the other benefits of trailing closure syntax (such as decreased nesting) which may be just as applicable to those use sites as they are elsewhere.

Exclusion from statement conditions

At present, the use of trailing closure syntax is excluded from if conditions and similar scenarios. This is sometimes diagnosed as a warning when the usage is merely confusable to humans but simple enough for the parser; in more complex scenarios, it is an outright parsing error:

if let x = [1, 2, 3].first { $0 > 2 } {
  print(x)
}
// Warning: Trailing closure in this context is confusable...
// Fix-it: Replace ' { $0 > 2 }' with '(where: { $0 > 2 })'

if let x = [1, 2, 3].first {
  $0 > 2
} {
  print(x)
}
// Error: Closure expression is unused
// Error: Consecutive statements on a line must be separated by ';'
// Error: Top-level statement cannot begin with a closure expression

In this case, there is one workaround for the user which does not require abandoning trailing closure syntax. Namely, the entire caller can be surrounded by parentheses instead:

if let x = ([1, 2, 3].first {
  $0 > 2
}) {
  print(x)
} // Prints "3"

Inability to disambiguate the intended matching parameter

Swift uses a "backwards scan" through function parameters to match an unlabeled trailing closure to a compatible parameter:

func frobnicate(a: () -> Int = { 42 }, b: (() -> Int)? = nil) {
  if let b = b {
    print(a(), b())
  } else {
    print(a(), "nil")
  }
}

frobnicate { 21 }     // Prints "42 21", equivalent to...
frobnicate(b: { 21 }) // Prints "42 21"

A user must abandon trailing closure syntax in order to specify that the given closure expression is intended to match the parameter labeled a:

frobnicate(a: { 21 }) // Prints "21 nil"

If the author of frobnicate revises the function to take an additional parameter of optional function type, the behavior of frobnicate { 21 } will change:

func frobnicate(a: () -> Int = { 42 }, b: (() -> Int)? = nil, c: Any? = nil) {
  if let b = b {
    print(a(), b())
  } else {
    print(a(), "nil")
  }
}

frobnicate(b: { 21 }) // Still prints "42 21", no longer equivalent to...
frobnicate { 21 }     // Now prints "42 nil"

This is not to suggest that APIs of the sort above are ideally designed. However, the language itself should, whenever possible, help API consumers write correct code even if API authors sometimes make questionable design choices.

A related limitation is the inability to disambiguate among two overloads which differ only by argument label if trailing closure syntax is used:

func frobnicate(ifSuccess: () -> Result<String, Error>) { /* ... */ }
func frobnicate(ifFailure: () -> Result<String, Error>) { /* ... */ }

frobnicate { .success("Hello, World!") }
// Error: Ambiguous use of 'frobnicate'
// Fix-it: Use an explicit argument label instead of a trailing closure to call
//   'frobnicate(ifSuccess:)'
// Fix-it: Use an explicit argument label instead of a trailing closure to call
//   'frobnicate(ifFailure:)'

Loss of meaningful words at the use site

Certain APIs have not been ideally designed for use with trailing closure syntax. In the standard library, for example, drop(while:) reads like a different function without its argument label:

let x = [1, 2, 3, 2, 1].drop { $0 < 2 }
print(x) // Prints "[2, 3, 2, 1]"

When there is only a single parameter, it can be straightforward for an API author to adapt to this limitation of trailing closure syntax by agglomerating the otherwise dropped words to the base name. Indeed, the API naming guidelines are now amended to suggest that authors take the issue into account for the first trailing closure.

However, making that same change would be more difficult in the case of APIs where the parameter in question is the last of several: there could be no straightforward base name that can satisfactorily substitute for an aptly labeled argument towards the end of the use site.

Moreover, when multiple parameters at the end of a parameter list are of function type, any of these could have its label dropped at the use site, since users may choose not to use trailing closure syntax for an arbitrary number of arguments (in order to preserve meaningful words, for example):

func frobnicate(_: [Int], excluding: (Int) -> Bool, transform: (Int) -> Int) {
  /* ... */
}

frobnicate([1, 2, 3, 2, 1].drop { $0 < 2 })
  { $0 < 2 } // We certainly don't want to leave this argument unlabeled!
  transform: { $0 * 2 }

frobnicate([1, 2, 3, 2, 1].drop { $0 < 2 }, excluding: { $0 < 2 })
  { $0 * 2 }

// So what can we accomplish merely changing the base name?
let frobnicateAfterExcludingByTransforming = frobnicate // šŸ¤Ø

Addressing this issue at the level of API design could require more disruptive changes, such as altering the order of arguments. This is a nontrivial ask of API authors to work around a deficiency in the language itself. Therefore, this proposal aims to provide a solution to address the problem at its root.

32 Likes

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

Detailed design

This proposal would modify the grammar, as already modified in SE-0279, by making the unlabeled expr-closure optional that precedes the remaining labeled trailing closures (identifier|keyword|'_') ':' expr-closure.

The grammar would be relaxed to allow the use of all-labeled trailing closures in the setting of statement conditions. The treatment of unlabeled trailing closures in that setting would remain unchanged.

The same rule regarding resolution of unescaped default: in favor of the keyword which currently applies to labeled trailing closures would be maintained and thereby also apply to a labeled first trailing closure.

The "backwards scan" rule for matching unlabeled trailing closures, as extended by SE-0279, would also be maintained. That is, a backwards scan through the parameters to bind all labeled trailing closures to parameters by matching labels would be performed. Then, if there is an unlabeled trailing closure, the same scan as has long been done for an unlabeled trailing closure would still be performed, starting from the last labeled parameter that was matched.

Source compatibility

This feature will be a purely additive change to the language and introduce no new grammatical ambiguities.

Effect on ABI stability

This feature will not have any effect on ABI stability.

Effect on API resilience

This feature will not have any effect on API resilience.

Future directions

A migration story

As the core team has said, trailing closures stand out as one of the few places where Swift allows the API consumer to decide whether or not to use a label. In general, these labels are regarded as part of the name of an API.

With the conviction that matching declaration and use sites by supporting labeled first trailing closures is the correct course of action, it would be tempting also to propose that the compiler should warn against label elision. From a technical perspective, diagnosing this usage would require trivial effort. A fix-it and a migration tool could lift all current usages seamlessly into a brave new world.

However, it would be prudent to approach the evolution of the language with a certain degree of humility afforded by a stepwise approach. Should our convictions be borne out by experience after implementation of this proposal, accompanied by adequate tooling, then API authors and consumers will naturally coalesce around a set of best practices and styles.

When API authors and consumers alike have had adequate time to adapt their code as a result of this proposal, then it becomes possible to consider one of two courses of action:

  • If, even after APIs have been reworked to account for this proposal, some still benefit from giving callers the flexibility to elide the label for the first trailing closure, while others benefit from use sites that always match their declarations, then we may wish to create an attribute to distinguish these two groups of APIs.

To choose a deliberately unsuitable name in the vein of @warn_unqualified_access, we might call this @warn_elided_trailing_label. If a more stringent approach is preferred, the compiler may even emit an error instead of a warning when the attribute is used.

  • If, ultimately, all or almost all APIs benefit from use sites that match their declarations, then we may move forward with an approach that diagnoses any instance of label elision (which does not include cases where the parameter is unlabeled, of course) with a warning and a fix-it.

  • If, ultimately, all or almost all APIs benefit from giving callers the flexibility to elide the label for the first trailing closure, then we would not need to take further action.

Alternatives considered

Removal of caller discretion in usage of labels

At Swift's present stage of evolution, a change that would only allow unlabeled trailing closures to match unlabeled parameters would be source-breaking for a very large number of existing use cases.

One might argue that labels could be made mandatory only in the case of multiple trailing closures. The rationale might be that, where there is more than one, there should be clarity as to "which one's what" in a way that's not necessary for single trailing closures.

However, even single trailing closures can need such clarification when there are multiple possible parameters that can match. Establishing a mandatory labeling rule only for multiple trailing closures would cause a divergence that is not self-evidently justifiable. Moreover, after multiple trailing closures are shipped as implemented for SE-0279, this too would be a source-breaking change.

Finally, even in that limited form, a mandatory rule would require the use of repetitive or unhelpful labels for "primary" trailing closures where API authors haven't yet reworked them, whereas this proposal would allow API consumers to delete those labels.

Instead, this proposal stakes out a claim that much of the benefit that can be gained from such a breaking change would be recovered in a source-compatible way by adopting this proposal and a consistent behavior throughout first-party (and, in the future, third-party) tools that default to omitting labels only for trailing closures that match unlabeled parameters. With time, label elision would cease to be the go-to option but rather a legacy feature supported for source compatibility reasons.

As detailed above (see "Future directions"), this proposal leaves room for future steps that would approach the same end result at a later stage of Swift's evolution. It should also be noted that there is precedent for code that's forbidden by Swift's grammar compiling anyway with a warning, as demonstrated above in the case of trailing closures used in statement conditions. It would be possible, thus, to deprecate label elision without actually breaking any existing source code by sticking to warnings instead of errors.

Typechecking changes

As the "backwards scan" rule can produce unintuitive results for matching unlabeled trailing closures to parameters, a source-breaking change in a future version of Swift could address the issue, making the results more intuitive. This would remain a possible avenue of exploration in the future, but it does not interfere with, nor preclude, the use of labeled trailing closures at the present time either to disambiguate for the compiler or to clarify for the human reader.

Indeed, the use of labeled trailing closures would minimize the extent of any deleterious effects of a source-breaking change to the typechecker that improves parameter matching for unlabeled trailing closures.

Acknowledgments

Matthew Johnson for close reading and suggestions.

38 Likes
Semi-related discussion about formatting/syntax representation

swift-format does use SwiftSyntax. That's what returns the parse tree representation.

SwiftSyntax uses the Swift parser as a dylib to parse a file/string of Swift source code and return a tree representation. It can only see what you have written in your source code. If you have this:

let x = [1, 2, 3].drop { $0 < 2 }

It has no way to know that the label while: is what you would have put there, because that requires type information from dependencies, and SwiftSyntax stops after the parse stage, before the semantic analysis/type checking stage. There's nothing to "preserve" because the information is not there in the first place.

Getting that information would require successfully compiling all of the project's dependencies and using something like SourceKit to query the API, but compiling an entire project to format it is not a realistic requirement.

2 Likes

As is abundantly clear from the review and acceptance threads, I am supportive of allowing labels on the first trailing closure. I think we should go a step further than this proposal though.

I think this direction would be reasonable as a step on the way to making this an error. If the community was willing to accept the churn this would cause (at our own pace, for now) I think it is a good direction. However, the Core Team doesn't appear to have an appetite for this level of change at this time.

I think introducing an attribute on functions that produces an error at call sites that elide a label is the next best direction. It would give library authors the same degree of control over trailing closure usage site that we have with regular function call syntax. It should be accompanied by a change to the API Design Guidelines recommending that this attribute be used any time a potentially first trailing closure has a label. This would make it clear that aligning declaration and usage is the recommended practice in Swift.

An attribute allows libraries to opt-in to usage site alignment at their own pace. Eventually, if the community broadly adopts this as a best practice we may be in a better position to require usage site alignment by default. If we do reach that point, the attribute could be deprecated and eventually removed.

We don't need to decide now whether the attribute is used as a final design or a step in a migration path. Either way, I believe it would provide a lot of value in delivering on the most important fundamental principle in the API Design guidelines: clarity at the point of use.

4 Likes

Hey folks (@Lantua, @wowbagger, @Jon_Shier, @jjatie, @anandabits, @allevato, @xwu and including myself), can we do one moderating thing please. This thread had a bumpy start, I wish that it wouldn't, so can we edit all our posts between the original announcement and the actual proposal in a way to not delete them but to hide them, so that the actual proposal moves up a little bit and the few posts at the begging would fade out? Feel free to pursue the discussion down-thread though.

[details="Summary"]
This text will be hidden
[/details]

I'd really appreciate if everyone would do that.

14 Likes

@xwu From my reading I think the following would be supported, but probably worth spelling out explicitly:

let item = [1, 2, 3, 4, 5].first() where: { $0 > 3 }

This also offers another resolution to the "juxtaposition of identifiers" issues noted in Objections.

2 Likes

Correct, there's no change in the existing rules regarding the optional parens in that scenario, which are and will continue to be allowed.

3 Likes