An attempt at future consistency for single trailing closures and subscript labels

I'd like to pitch a potential model for getting single trailing closures and subscript labels to be consistent with the language. This builds on @xwu's proposal for allowing optional labels for single trailing closures, and also should be read in the context of the Core Team's response.

A note that I haven't quite done my homework here in terms of reading all of the numerous prior discussions and comments, so forgive me in the event this idea has been brought up before or is a non-starter.

Trailing Closures

The core concept is this: we introduce the ability for API authors to control the call site for single trailing closures as we would with an attribute, but rather than using an attribute we use the (_ label:) and (label label:) syntaxes and behaviours as follows:

// Currently: can be called as `doAction {}`
// Under this and @xwu's proposal: can be called as either `doAction {}` 
// or `doAction closure: {}`
func doAction(closure: () -> Void) {}

// Currently: Must be called as `doAction {}` when using trailing 
// closure syntax.
// Unchanged in this proposal.
func doAction(_ closure: () -> Void) {}

// Currently: Called as `doAction {}`
// Under this proposal, must be called as `doAction closure: {}`.
func doAction(closure closure: () -> Void) {}

The non-trailing-closure variants of these calls would work as they currently do.

The important caveat of this approach is that this is source-breaking for users of any API authors which had an explicitly provided exterior label (the func doAction(closure closure: () -> Void) {} form), since now such calls must be used with a label. However, in cases where the API author has gone out of their way to label the user parameter separately from the interior parameter name, I suspect it's likely that they intend for the label to be explicitly provided, and this may be acceptable source breakage.

Subscripts

As an extension, we could apply this same principle to make subscript labels consistent with the rest of the language as shown below:

struct SomeType {
    // Currently: must be called as object[value]
    // In this proposal: can be called as either object[x: value] (preferred)
    // or object[value] (source compatibility).
    subscript(x: Int) { return x }

    // Currently: must be called as object[value]; ambiguous with/ 
    // same meaning as subscript(x: Int)
    // Unchanged in this proposal.
    subscript(_ x: Int) { return x }

    // Currently: must be called as object[x: value]
    // Unchanged in this proposal.
    subscript(x x: Int) { return x }
}

This has a weaker argument in its favour. It is source breaking for types that have both labelled and unlabelled subscripts where the interior label is the same as the exterior label for another method. It also removes control of the call site for existing APIs, since now users have the option for whether to provide the label. However, it does create a path for future consistency for the language.

For the subscript case, the behaviour could potentially be gated on Swift language versions, wherein code compiled with Swift <= 5 without an explicit exterior label (i.e. subscript(x: Int)) is treated as if it had been written as subscript(_ x: Int).

Evaluation against Evolution Principles

In terms of how these intersect with the Core Team's outlined principles:

  • Source Stability: This proposal is source-breaking for a (hopefully small) percentage of code where it is likely that the caller is using syntax contrary to the API author's intent (i.e. where the (withParam param:) syntax is used). It additionally will break subscripts where the author has explicitly given the caller the ability to choose their syntax (subscript(x:) and subscript(x x:) within the same type); however, the migration for this is fairly trivial. As a result of source compatibility concerns this proposal would need to be only applied under a new language version.

  • Control of the Call Site: This pitch adds forms for which the author has full control over the call site, although it does not remove control from the caller (for preserving source compatibility) and, for the case of subscripts, introduces a new way for callers to call existing declarations (or at least those compiled in the Swift 6+ language mode), which does run counter to the "Control of the Call Site" principal.

  • Language Consistency and Future Directions: This proposal introduces a consistent model that could potentially support deprecating the old/ambiguous syntax in a number of years. Tooling would guide users to the preferred options (i.e. labelling consistent with standard functions/methods, where _ indicates no label and label: means that label should be included at the call site. The legacy ways of calling would exist for source compatibility but should not be encouraged going forward.


Even though there are a few issues that I've outlined, and I'm sure a few more I haven't thought of, I'm hoping this might be a launching board for other's improvements and discussions. Even with the issues, I personally feel that this would be an improvement over the current state and would allow both writing more consistent code and designing more consistent APIs going forward.

3 Likes

This is an interesting thought, but most closure labels are already distinct from their internal names—e.g., sort(by areInIncreasingOrder:)—so personally I'm wary of introducing a special rule for closure labels for the minority case.

Moreover, it would still have to be a warning and not an error because the core team has articulated that the source stability principle is a must and not a should. The result would be that you would get fewer warnings about elided labels under this alternative than under my draft proposal rules until API authors rework their APIs, but still we cannot make any label elision an error.

I worry that it would make the language less teachable and also not accommodate the likely scenario where someone inevitably asks, "What if I still want a different closure label from an internal name but not the warning?"—and it seems unlikely that we would want to accommodate that use by allowing a third component: sort(_ by areInIncreasingOrder:).

I posted some thoughts on how my draft proposal fares in terms of the core team principles over in the other thread, and I wonder if we could consolidate the design exploration in that thread (in part also to refocus that conversation also).

1 Like

If you feel this would be better placed in the other thread please feel free to move it there (assuming Discourse allows that; otherwise I’m happy to close this and shift the opening post there manually). I originally posted this separately since I think it’s somewhat separate from the discussion of the principles themselves.

I think this is the ideal solution. Yes, it is source-breaking, but the temporary pain pales in comparison to the long term gain.

To use @Ben_Cohen's analogy, the other thread is about how to fish as opposed to creating a list of fish. I'm not totally sure what that means, but, exploration of the various different ways of solving the trailing-closure label problem likely will involve a series of pitches and pitch threads, running in parallel. Hopefully, we will see some sort of convergence among those pitches. I suspect this process will take quite some time.

I'd be interested to know how widespread cases like this are, where a closure label is different from the internal name but does not suffer from a lack of readability when called in the current trailing closure syntax. I'm not sure how to go about gathering that data, though.

You're effectively asking how an API author would design an API where the label is only present when called without trailing closure syntax. The answer to this would be that the API author provide two methods: sort(_ areInIncreasingOrder:) and sort(by areInIncreasingOrder:), as they would with any other function they wished to make callable with or without an argument label. In fact, this proposal would likely require that the standard library do exactly that for source compatibility, since sort { $0 < $1 } doesn't seem actively harmful to me even though sort by: { $0 < $1 } reads better.

3 Likes

This would also enable sort({$0 < $1}). It's no more harmful than the unlabeled trailing closure syntax however.


A different direction that wouldn't require duplicating all the functions is to have a way to tell when label is optional. For instance:

mutating func sort(by? areInIncreasingOrder: (T, T) -> Bool)

We could restrict label optionality to the trailing closure syntax. Or it could also enable optional label inside the parenthesis if we wanted to. I'm not sure if there's a good justification for the later or if it'd make overload resolution too painful, but it'd make things more symmetric.

I have been wondering about the same thing @michelf just proposed. Swift already has idiomatic ways to talk about things that are "optional"; what if we use that same idiom? What if we use ? and ! to mark the required-ness or non-required-ness of trailing closures?

// Label 'handler' is marked optional using '?'. The label 
// may be present in a trailing closure, or it may be 
// elided.
func requestAccess(to resource: String, handler?: ()->Void)

// These are both okay, because the label is optional
requestAccess(to: "camera") { /* do stuff */ }

requestAccess(to: "camera") 
  handler: { /* do stuff */ }
// Label 'handler' is marked *required* using '!'. The label
// *must* be present when called with a trailing closure
func requestAccess(to resource: String, handler!: ()->Void)

// Trailing closure label is *required*
requestAccess(to: "camera")
  handler: { /* do stuff */}

Labels without ? or ! would continue to work as they currently do

// Existing behavior unchanged; trailing closure label elided.
func requestAccess(to resource: String, handler: ()->Void)

// Usage remains unchanged
requestAccess(to: "camera") { /* do stuff */ }
// Explicitly requesting no trailing closure. (This already
// works, but gains new significance with the possibility 
// of single-trailing-closure labels.)
func requestAccess(to resource: String, _ handler: ()->Void)

// Usage remains unchanged
requestAccess(to: "camera") { /* do stuff */ }

Of course, if a parameter label is marked as optional, then it seems like it should be optional in the non-trailing syntax as well?

func requestAccess(to resource: String, handler?: ()->Void)

// These are both okay too, I guess?
requestAccess(to: "camera", handler: { /* do stuff */ })
requestAccess(to: "camera", { /* do stuff */ })

We don't currently have a way to force a parameter to be called with a trailing closure, so any decorator that affects the label in trailing-closure syntax is necessarily conflated with the non-trailing syntax, but maybe that's okay?

What do you think?

1 Like