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.