Principles for Trailing Closure Evolution Proposals

Is it possible to have an attribute that acts as follows?

func foo(first: () -> Void, second: () -> Void) { ... }

foo { } second: { }

@explicitLabelControl
func foo(first: () -> Void, second: () -> Void) { ... }

foo first: { } second: { }

@explicitLabelControl
func foo(_ first: () -> Void, second: () -> Void) { ... }

foo { } second: { }

1 Like

How would you envision that interacting with the single trailing closure syntax?

I believe it would work the same way

func foo(completion: () -> Void) { ... }

foo { }

@explicitLabelControl
func foo(completion: () -> Void) { ... }

foo completion: { }

@explicitLabelControl
func foo(_ completion: () -> Void) { ... }

foo { }

No, mean you need to account for the single trailing closure case with the multiple closure case, especially with a sequence of defaulted closure parameters.

I just had a crazy idea.

  1. We change the rules of the language so that closure argument labels are included or omitted under the same rules as other argument labels: The definition site either omits the label with an underscore or else it must be used at the call site.

  2. Source compatibility is maintained by the migration assistant in one of two ways (I literally think this could be a choice given to the person who is updating their codebase):

    • The migration assistant will automatically update call sites to use labels where required by the new rule.
    • The migration assistant will add shim functions to a new file in the destination project (this new file would only contain shims for this purpose). These shim definitions add a second signature that omits the label of the first trailing closure using an underscore.

This way, source compatibility is not technically maintained and yet no source code needs to change (the shims are additive) to adopt the new language version. Coders can delete the shims at their leisure or some really nice person could create a tool to automate that kind of ad-hoc migration off of the shims.

Even aside from this idea being totally out of left field as far as I can tell, there's a hiccup: For functions that provide the opportunity for a single trailing closure at most, there is 1 shim. For functions that provide the opportunity for n trailing closures, there are n shims; this simply re-illustrates the fact that multiple trailing closures can be used in n different ways at the call site.

On a perhaps less crazy note: Are we opposed to source breaking on principle or is there some possibility that some migration story exists (not necessarily the one I just thought of) that is good enough to tip the scales on a source breaking change? Such a migration strategy would almost certainly mean more work for those creating migration tooling, but is that a way to shift the equation?

4 Likes

?
That's what I (and many others) have proposed. I thought this was part of the discussion all along. What am I missing?

I didn't call this out explicitly, but I thought it would go without saying that whenever we have a syntax change we would expect the migration assistant to help.

This is an interesting idea, generating a shim. We could generate shims only for single trailing closures; since multiple trailing closures haven't shipped, we don't have anything to break. Also, only shims that are actually required need to be generated; so if a user doesn't spell the same call in multiple ways, then we don't need multiple shims.

2 Likes

You didn’t miss anything; my crazy idea was to avoid source compatibility via a shim and even offer the end user the two alternative migration paths. I just wanted to present my additional idea in the context of a complete migration strategy.

1 Like

Oh, sorry, I see it now. I played around a bit and I already encountered some problems:

@explicitLabelControl
func foo(first: (() -> Void)? = nil, second: (() -> Void)? = nil) {  }

foo(first: { }, second: { })
foo first: { } second: { }

foo(first: { })
foo first: { }

foo(second: { })
foo second: { }

@explicitLabelControl
func foo(_ first: (() -> Void)? = nil, second: (() -> Void)? = nil) {  }

foo({ }, second: { })
foo { } second: { }

foo({ })
foo { }

foo(second: { })
foo second: { }

@explicitLabelControl
func foo(_ first: (() -> Void)? = nil, _ second: (() -> Void)? = nil) {  }

foo({ }, { })
foo { } { }

// here there is obviously a problem
foo({ })
foo { }

But just to make my point clear in suggesting this attribute: It would allow the author to explicitly choose to treat closures with the same rules as other parameters, and reflect this in trailing syntax. If there is a consensus in the future that this behavior should be standard, it is just a matter of deprecate the attribute and thinking about how the migration will be. I don't even know if this is possible, so I apologize if I'm talking nonsense.

My understanding is that, and a member of the Core Team can correct me if this is not the case, a strong auto-migration path is pretty much a requirement for any source breaking change. That is, simply providing a migration tool is not sufficient for a change to be considered non-breaking.

Yes. Perhaps I minced words too much. My most salient thought was "should the strength of the migration story factor into whether it is worth a breaking change explicitly?" because right now it is not one of the listed considerations. What if we are ruling out a change because it is breaking before we have brainstormed migration strategies enough to find a strategy that makes us go "oh! That actually could make it easy and safe enough to migrate that it is worth it." In my example above, maybe the "safe enough" bit means that the end-user's code is not even directly modified during migration (because shims are added).

1 Like

I guess the solution is for Swift to allow suppressing warnings. Opinionated language with opinions that can... be changed as time goes by :)?

1 Like

Using the principles stated (ignoring source stability for now), it seems obvious that trailing closures in their current state have design flaws. The ideal solution seems to be that a function foo(body: () -> Void) (#1) cannot be called with a trailing closure that omits the argument label while foo(_ body: () -> Void) (#2) can. This is intuitive as it makes the labels for closures work along the lines of how regular argument labels work in Swift. Also, this returns the author of code control over their call-site.

Circling back around though, this of course does violate the principle of source stability. All code invocations to #1 in code wherein a trailing closure are used immediately become invalid code. This sucks because it puts us between a rock and a hard place, we want to guarantee that old code continues to compile, while we can't do that if we fix the behaviour how I outlined.

Is there any way to feasibly get around the probably of source stability now and in the future? I don't like that we are held back by "we made a bad design decision in the past and now we have to live with it forever." Is there any way that we could specify compiler versions on certain project and modules to remedy the issue? More generally, is there any way to be able to introduce breaking changes while upholding the integrity of older codebases that were made with different versions of Swift. Otherwise, it seems that the idea to add in breaking changes every few years is the best one. While it means that older codebases have to update to get the new features, it allows us to truly fix mistakes in the language rather than be weighed down by them forever. Also, this could be made substantially easier with a good migrator.

I view Swift as a modern language that really improves expressivity beyond other programming languages as there are many things that we got right from the get go, but I fear that for the sake of making old code work, we sacrifice the true potential of the evolution of the language. Although we are encountering the burdens of source stability now while shaking our heads at prior design choices, this definitely won't be the last time it becomes an issue when we are trying to fix Swift's design. As such, I think introducing a framework for including important source breaking changes like this is essential, whether that be releasing a source breaking versions of Swift every X year, finding a way to use modules written in a different version of Swift or something else. Swift is quite a young language in the grand scheme of things and us sacrificing its features for decades to come just based off of the few years Swift has emerged seems silly.

I was thinking about being able to interoperate between compiler versions of Swift. While I certainly am not an expert in the subject, I see some problems come to mind straight away. Take trailing closures for examples, if we were to make the aforementioned changes to make their behaviour consistent with other argument labels, many call-sites would be rendered invalid. It is suggested in the original post to make an attribute that requires closures follow these rules, but I think that an opposite attribute would maybe be in order. Instead of making the right behaviour require an attribute, we could make the old behaviour annotated with an attribute. So if I am writing a codebase in a new version of Swift using a package/module/project written in Swift 5 for example, all functions that can have trailing closures would be imported to the new project under an automatically applied attribute specifying that it can omit the label for a trailing closure (maybe @canOmitLabelInTrailingClosure). The ideal outcome of this is to make the old behaviour more burdensome to keep around than the new one, thus incentivizing the use of the better behaviour, not the old one. This is ideal as it makes the old behaviour an edge case while making the new streamlined. This also seems to fix the issue of source stability. Maybe this is a viable solution, if it is, it seems that we'll be able to have our cake and eat it too. Are there any other important underlying problems to a solution like this that I'm missing or that would need to be addressed?

Alternatively, we can go down the route of including breaking changes every X years. In the OP, it says that source breaking changes must have substantial weight behind them. I believe that in the case of trailing closures its current design is harmful and making it consistent with the language would have clear improvements. Unlike OP though, I don't really view a ton of uses of a bad design choice to justify keeping that bad design around in perpetuity. Rather, I feel that it is something that should be fixed ASAP so that we don't have more uses of a badly design feature before it can be remedied.

8 Likes

I haven't followed every post in this thread (sorry) but in case it hasn't come up yet:

One way to "soft migrate" code is a multistep journey:

  1. Enable a new behavior without removing or warning about old behavior.
  2. Change Xcode code completion to start suggesting the new behavior.
  3. Warn about old behavior.
  4. Error about old behavior.

I haven't seen (but again haven't read) any discussion about step #2. This is a small way that could help reduce the cost of warnings in practice, at least for transitions that take multiple years.

-Chris

19 Likes

I have to say that I'd be very disappointed if this thread slid inexorably to a conclusion that the existing single closure syntax was to become deprecated or a warning in the long or short term. If it can be parsed to give a warning, it can be parsed.

I’m supportive of the compromise position to follow up SE-0279 to allow optionally supplying the label on the first closure and it the function author wants to mandate that the label be supplied at the call site why not use a repetition in the function signature doubling the specification of the label rather than contrive more attributes. This idiom is already used to indicate subscript arguments require labels viz:

Binding.init(get get: @escaping () -> Value, set set: @escaping (Value, Transaction) -> Void)

With all my respect but I would be highly against the "repetition" approach. The rules in operator and subscript labeling are already confusing enough, it simply doesn't make sense to me to mix that with yet another behavior just for trailing closure labels. I'd rather use several attributes, but not get get or set set.

6 Likes

Didn't think about this, before the warning. It would be definitely smoother: the principle of API author control would be respected in perspective, that is, not immediately but over a series of steps that converge to a certain goal.

And the goal should not be adding another @attribute. I completely disagree with any approach based on a @attribute, wherever it's put: there's already a way in Swift to define an API with omitted argument labels, and we shouldn't replace a mistake with another, just for the sake of preventing a very minor annoyance.

8 Likes

I think that was a mentioned in @xwu proposal in the Tooling section.

Nonetheless, I wondered if it would be reasonable to have the migration tool built into the compiler. This way if the compiler warned at least about an n number of unlabeled trailing closure expressions, the compiler would suggest fixing them all:

func foo(label: () -> Void) { ... } 

// in use

foo { ... } // ⚠️ no label 
 
... // after five unlabeled ‘foo’ call 

// ℹ️  Would you like to replace all unlabeled 
// trailing closure expression with labeled ones?

Absolutely. And while I, for one, would be in favor of no step 3 or 4, I'd be happy with whatever prevailing style and practices the community adopted to over a reasonable time, not just the span of 4 forum threads.

And while the functions I write in the meantime will have labels for when I pass in functions:

doActionFoo(with: someData, success: completionFunc, failure: cleanupFunc)

and I'll omit the leading label when passing closures when I think it makes sense, inconsistent as it may be:

doActionFoo(with: someData) {
  ...
} failure: {
  ...
}

I'll be happy to make changes if the day of step 3 or 4 ever come.

2 Likes

It seems that Apple's solution to the designability and usability issues I pointed out earlier is to overload APIs which use multiple closures. For example, the new Gauge SwiftUI view on watchOS 7 has 5 overloads for its initializer:

init<V>(value: V, in: ClosedRange<V>, label: () -> Label)
init<V>(value: V, in: ClosedRange<V>, label: () -> Label, currentValueLabel: () -> CurrentValueLabel)
init<V>(value: V, in: ClosedRange<V>, label: () -> Label, currentValueLabel: () -> CurrentValueLabel, markedValueLabels: () -> MarkedValueLabels)
init<V>(value: V, in: ClosedRange<V>, label: () -> Label, currentValueLabel: () -> CurrentValueLabel, minimumValueLabel: () -> BoundsLabel, maximumValueLabel: () -> BoundsLabel)
init<V>(value: V, in: ClosedRange<V>, label: () -> Label, currentValueLabel: () -> CurrentValueLabel, minimumValueLabel: () -> BoundsLabel, maximumValueLabel: () -> BoundsLabel, markedValueLabels: () -> MarkedValueLabels)

Personally, I think this approach is deeply flawed, from both designability and usability standpoints. But fundamentally I think it indicates that the post-0279 syntax isn't up to the job of producing APIs using multiple closures. Requiring overloading to produce a usable API places a huge burden on API designers, both in initial work and long term maintenance. I think it's more likely most designers either won't implement such overloads, making for a subpar user experience, or they'll avoid multiple closure APIs altogether. Neither is good for the community.

I would hope that Apple's own experience creating multiple closure APIs would've produced the experience necessary for further consideration on this topic, but that seems unlikely at this point.

@Ben_Cohen Are there any more details to share about the path forward here, or any response to the various issues raised in this thread?

46 Likes

To be clear, addressing SE-0279 in the way you are describing would not eliminate these overloads from SwiftUI. You've omitted the generic constraints, which are important here. The first two overloads are more like this, in context:

struct Gauge<Label: View, CurrentValueLabel: View> {
  init<V>(value: V, in: ClosedRange<V>, label: () -> Label) where CurrentValueLabel == EmptyView
  init<V>(value: V, in: ClosedRange<V>, label: () -> Label, currentValueLabel: () -> CurrentValueLabel)
}

If you took away the first overload, there would be no way to write a default parameter for currentValueLabel in the current language that would infer a generic argument for CurrentValueLabel.

It's not that your critique is invalid, but this API is not an example of the problem you describe.

Doug

2 Likes
Terms of Service

Privacy Policy

Cookie Policy