[Discussion] Eliding `some` in Swift 6

Swift 5.7 includes a suite of new language features that together enhance the expressivity and usability of any and some types, making abstract code more approachable and easier to internalize.

In addition to making a lot of common generic code patterns easier to write by eliding an explicit generic signature completely, Swift 5.7 makes it very easy to switch between any types and some types. Currently, the default for writing a plain protocol name in type position is any, but many of these implicit any uses could be replaced with some, giving them more type information while still behaving correctly.

The any keyword will be required in Swift 6, which opens an opportunity to change the default when writing a plain protocol name to some instead of any. This post explores the motivation and implications for making such a change.

Feedback from your hands-on experience internalizing and applying the new generics features in Swift 5.7 is welcome and crucial for informing this exploration! In particular, I'm interested to know things such as:

  • Whether you find the some keyword in opaque result types more clarifying now that (hopefully) programmers are starting to gain a better understanding of opaque result types. Pre-5.7, I'd received a lot of feedback that people don't know what some really means and they just stick it into their SwiftUI views to satisfy the compiler.
  • What kinds of code are you able to modernize in Swift 5.7?
  • How often do you use any versus some?
  • Where are you able to change any to some (or insert some in front of a bare protocol name in existing code)?

Use cases for some and any

The some and any keywords have different capabilities, and will be useful in different contexts in a project.

some guarantees a fixed underlying type, which preserves static type relationships to that underlying type, giving you full access to the protocol requirements and extension methods that use Self and associated types. These are the semantics that programmers typically expect when working with protocols, and have many use cases in abstract code, including (but not limited to):

  • Local let-constants, including for-in variables, e.g. when iterating over a heterogeneous collection. Because the variable cannot be re-assigned to have a different underlying type, there’s little reason to use any instead of some.
  • Local vars whose underlying type does not change (only the value changes).
  • Non-inout function and method parameters, and inout parameters whose underlying type does not change (only the value changes).
  • Single-statement computed property or method result types, and result builder bodies that preserve type information such as @ViewBuilder and similar DSLs.

any provides a common supertype for all concrete types that meet the given requirements. Using any is a form of type erasure that allows you to store any arbitrary subtype dynamically by using a boxed representation in memory. Type erasure has a number of use cases where you need the ability to change or mix different underlying types, such as:

  • Local let -constants that have different initial types across different control-flow paths, and vars whose underlying type changes.
  • Heterogeneous collections, e.g. [any DataSourceObserver]
  • Optionals that are assigned an underlying type later, e.g. var delegate: (any UITableViewDelegate)? = nil
  • Stored properties of types that use a protocol abstraction, but the enclosing type is not parameterized. The delegate pattern above is a common example of abstraction as an implementation detail of a concrete type.

For any types, where the underlying type can vary, the protocol requirements and methods involving Self and associated types are not available from the box itself; if the underlying type can vary, so can the protocol requirements that depend on that type, so there is no way to know what the signature of a requirement using Self or associated types in contravariant or invariant position is. For this reason, programmers are encouraged to write some by default, and change some to any when the storage flexibility is needed. This workflow is similar to writing let constants by default until mutation is needed.

Should some be the default in Swift 6?

Encouraging writing some by default begs the question of whether some should be the default for a plain protocol name in the language, just like how let is the default whenever the variable introducer is omitted, such as in parameters and for-in loops. In Swift 6, a plain protocol name P could still be valid code, but it would mean some P instead of any P.

Writing any explicitly is important for understanding the type-erasing semantics and the limitations of using this type, including the ability to change the underlying type, the inability to access Self and associated type requirements, etc. These limitations don’t exist for some types, which provide full access to the protocol requirements and extension methods. In fact, some already is the default for protocol extensions, e.g.

// Extends all concrete types conforming to 'Collection'.
// The 'Self' type parameter serves as a placeholder for the
// concrete type conforming to 'Collection'.
extension Collection { ... }

In a protocol extension, you write generic code that operates on a Self type parameter that conforms to the protocol. In this case, the bare protocol name is already sugar for declaring a type parameter conforming to the protocol. Protocol extensions have proven to be a very natural way to write generic code. The same principle could be applied to plain protocol names in Swift 6 to enable programmers to write generic functions naturally without having to fully internalize explicit generic signatures with where clauses.

In many use cases for some, it’s already evident in the code that the underlying type will never change. For example, a let constant or a non-inout parameter already indicates that the underlying type cannot change. Similarly, opaque result types in single-return methods and result builders clearly have one underlying return type.

In Swift 5.7, you can write a zip function using the some keyword for both arguments and the result type:

func zip<E1, E2>(_: some Sequence<E1>, _: some Sequence<E2>) -> some Sequence<(E1, E2)>

Eliding the some keyword, this declaration would read:

func zip<E1, E2>(_: Sequence<E1>, _: Sequence<E2>) -> Sequence<(E1, E2)>

In addition to eliding the some keyword in type annotations, the language could default to some in more places, such as local variable assignments to an any type without a type annotation, where it’s possible to open the existential value:

let observers: [any DataSourceObserver]

for observer in observers {
  // The default for `observer` could be (some) `DataSourceObserver`,
  // which means the scope of this for-loop would be a context
  // where you have access to Self and associated types
}

Observations and open questions

Brainstorming this idea has surfaced a number of observations that are worth discussing.

First, there are a few cases where the some keyword could be considered clarifying. For example, the some in [some Comparable] communicates that the array is homogeneous. Similarly, in var value: some P = ConcreteP(), the some keyword conveys that the underlying type cannot change.

Composing type parameters with Optional is tricky to work with, because passing nil doesn’t provide a generic argument type, so replacing any with some for optional function parameters might not be as straightforward as changing the keyword. One of the main use cases of optional any parameters could be changed to use SE-0347 instead, and provide a concrete default argument instead of nil.

Eliding some is not possible when only using a superclass constraint. There isn’t much benefit to writing some ClassType over using the class type directly, so this is probably okay.

An obvious downside of changing the semantics of existing syntax in Swift 6 is that numerous resources, such as Swift Forum questions and blog posts, have been created over the years using the existing syntax. This content will become outdated with Swift 6, where previously-existential code would have different semantics. However, programmers will already need to evolve their understanding of any types with SE-0309 in Swift 5.7, which introduces the need to reason about uses of associated types in protocol requirements when working with any types. So, content that features existential types will already need to be updated to apply to Swift 6, and there’s a fair amount of existing existential code that could flip to using generics and still behave correctly, as mentioned earlier.

Finally, because a lot of existing code can switch from any to some and still behave correctly, eliding some may ease the transition to Swift 6 by requiring less code churn, and even improving the semantics of some existing Swift code.

Alternatives considered

In this post, I’ve outlined the common use cases for some and any. A common suggestion is to formalize the heuristics for when some is most useful versus when any is most useful, and use those heuristics as rules for defaulting to some in certain cases and defaulting to any in others. However, this model would re-introduce the conflation between the semantics of existential types and opaque types, and it would lead to a frustrating developer experience in the face of refactoring and code evolution. Innocuous changes, such as factoring a local variable into a stored property, would end up changing the type of values unexpectedly and cause programmers to re-structure code that is seemingly unrelated to the refactoring task at hand, such as moving code that operated on that local variable into a separate function accepting some.

26 Likes

My gut reaction is that it feels somewhat... aggressive to change the behavior of bare P in the same release where we're requiring the use of any. I think I'd prefer if we give some time for Swift 6 to 'settle', and then reintroduce the 'bare P' syntax in a future release (and since it's purely additive syntax in Swift 6.x, it could even be a minor release).

More concretely, using an example raised in your post:

Similarly, in var value: some P = ConcreteP(), the some keyword conveys that the underlying type cannot change.

Long term (say, Swift 7 and beyond), I'm not sure it makes a ton of sense to require some P here instead of just P, even if users could more clearly document their intent by writing some. But if Swift 6.0 allows P to mean some P in this position, we'd be silently changing behavior from Swift 5.x where value had type any P, which I think is also untenable.

So that would leave the transition in a middle ground where we'd change the meaning of bare P in some circumstances but emit an error in others, only to eventually enable it everywhere. That seems... overly messy to me compared to first requiring the removal of all 'bare P' usages to transition to Swift 6, followed by the incremental introduction of the bare-P-as-some-P syntax.

31 Likes

My initial reaction to this is that the words “some” and “any” provide crucial mental scaffolding for these confusing questions, and it's best to keep them in the mix whenever explicitly referring to a type by name.

We shouldn’t ever think of “just P” being a type that a variable or function can have; it’s always either some P or any P. Both come with limitations that are surprising if we don't have the idea of “some” or “any” firmly fixed in mind when reasoning about the type.

One of the best things about where we’re currently heading with Swift 6 is that this distinction is clear, and we have a clear language for talking about it. And that clear language shows up in the syntax in a way that supports casual heuristics as a stepping stone to deeper understanding, in keeping with Swift’s general design principle of progressive disclosure.

In situations where there is no explicit type name, defaulting to some seems fine. So, for example, this makes perfect sense — is positively beneficial, even, since it’s not obvious that some is both allowed and better in this situation:

let observers: [any DataSourceObserver]
for observer in observers {  // `observer` is `some DataSourceObserver`
   …
}

This, however, strikes me as pretty dicey:

func zip<E1, E2>(_: Sequence<E1>, _: Sequence<E2>) -> Sequence<(E1, E2)>

Concerns about migration path and accidental “any becomes some” effects of grabbing pre-6 example code are also compelling. At a minimum, a phased rollout seems wise.

However, even imagining this as a green field problem, I tend to prefer explicit some vs. any. If that distinction exists at all in the language, it should be very clear.

I may well revise my opinion about all this after diving in with Swift 5.7.


As an aside, the “Use cases for some and any” section is one of the best short descriptions I’ve read of what this distinction means in practice for developers. Distribute that text far and wide!

35 Likes

We don't technically have a schedule for Swift 6 yet, but I share the concern that this seems like a very fast turnaround for re-using bare protocol names with different semantics. Adding any isn't something that's had much time to gain much traction in the community and the collective Swift codebase. I think it would be wiser to give people more time to wrap their heads around the post-5.7 world of generics.

35 Likes

One potential midpoint we could take is to make some the default in places where some or any doesn't really affect the domain of a function, particularly for non-inout function arguments. With the recent additions to generalize existentials and allow for implicit opening, func foo(_: some P) and func foo(_: any P) both accept the same sets of parameters and have the same sets of capabilities operating on their arguments, but func foo(_: some P) is slightly more efficient as public ABI because it can accept a conforming value as-is without boxing it, and also more flexible by default inside the body of foo, since you can use all of P's methods on it without an opening step. It would make sense to favor the more efficient, more expressive interpretation of func foo(_: P) to mean func foo(_: some P), independent of what we choose to do in places where there's an effective semantic difference.

3 Likes

I don't think this is true - the function accepting any P does not have the ability to access Self and associated type requirements in invariant or covariant position in protocol methods. If you try in Swift 5.7, you'll get an error and a fix-it to change any to some.

I also think taking this approach will lead to a confusing model because programmers will need to deeply understand where they can omit some and where they cannot. For example, you'd be able to write a parameter of type P, but not of type [P].

5 Likes

Not directly on the argument, but you can still open it locally without changing the external interface:

func foo(_ x: any P) {
  func foo2(_ x: some P) { ... }
  foo2(x)
}

Suggesting changing the outer argument type from any to some might not be possible if the argument is existing public API, since that's an ABI-breaking change. I think it'd be great to encourage the "easy way" of writing a function that takes all values conforming to a protocol to be the most efficient one by default. I see what you're saying about it being potentially confusing exactly when you can elide some/any. On the other hand, the compiler can help here too, and it might be easier to explain why you need some or any in contexts where the choice has obvious unsubtle effects on what the function can do.

2 Likes

That’s kind of the “middle ground” I was getting at above, and I really don’t think it’s a very tractable transition process. Bare P would be accessible in only a limited set of places, and I agree with @hborla —I think it would be difficult to explain why.

Also, even for function parameters I don’t think the ‘direct’ transition is quiiiite as transparent as we’d want. E.g., the following would start failing on the let f = ... line, right? I think it's much better if this becomes a compilation error on the line that defines takesBareP.

func takesBareP(_: P) {}

let f = takesBareP
3 Likes

There are already a lot of rules around opening existentials in SE-0352, which may be surprising in some cases. For example, printing the type of an implicitly opened parameter can actually output the opaque type, which may surprise users that aren't really up-to-date with the latest evolution proposals. I think this was acceptable in the case of SE-0352 as a compromise; here, however, we could leave things as they are, such that there's a clear distinction between generics and existentials.

I agree with @Jumhyn that at least for now, some shouldn't be elided, but I think it would make for a good quality-of-life improvement in the future.

I don't think keeping some everywhere will help progressive disclosure in the long run. In function parameters, for example, the bare syntax implying an existential, made sense to a lot of users (before running into an edge case). Similarly, since some will become the default, I think it will be intuitive to give it the bare-protocol syntax since this syntax doesn't have an intrinsic characteristic hinting at either generics or existentials. Then again, progressive disclosure may not be desired here, since the type of abstraction is arguable an important detail, and thus justifies a steeper learning curve.

3 Likes

I agree with @Jumhyn, @John_McCall and @filip-sakel that the overall direction is very nice but the timeline seems to be too compressed.

I think having no major version between prohibiting bare protocol syntax and adopting it for another meaning effectively eliminates any transition at all: the net effect would then be a silent change in the meaning of existing code which, as I’ve argued in other scenarios, is one of the least desirable sorts of source-breaking change.

The community has really mobilized with some wonderful tutorials explaining the upcoming accepted changes. It seems that it would add to confusion rather than reduce it if users were to find that code they’re learning is no longer going to work somehow still compiles, contrary to all of these wonderful tutorials, and then only to discover down the line that it works differently.

15 Likes

I suspect that “any by default” may make sense to people because in the vast majority of statically typed languages that people are coming from — Objective-C, Java, Typescript, C#, Go, Dart, Scala, even C++ — the bare name of an abstract type indicates an existential of some kind.

Implementation details are all over the map, and not all of them refer to a value-side box as in Swift…but in all of them, the user-visible presentation of a variable x of type InterfaceOrTraitOrProtocolName behaves like Swift’s any: runtime type of a mutable variable can change, same-associated-type knowledge requires a generic, collections can be heterogenous, methods are dynamically dispatched, etc.

In fact, AFAIK, nothing like Swift’s opaque return types even exists at the user-visible surface any of these languages. They do exist under the hood as a compiler optimization in many of these, but for the language user's point of view, everything looks like an existential type. Only Rust has a surfaced distinction (impl / dyn) similar to Swift’s (some / any).

Again, I might change my tune after spending some quality time with Swift 5.7, but it does seem to me that “same by default” runs upstream against a whole lot of precedent.

9 Likes

IMHO this is quite logical and worth doing.
Edit: I take this back, the following message convinced me that keeping explicit "some" is a good idea.

I'd even drop the first angle brackets... they could be derived:

func zip<E1, E2>(_: Sequence<E1>, _: Sequence<E2>) -> Sequence<(E1, E2)>
↔
func zip(_: Sequence<E1>, _: Sequence<E2>) -> Sequence<(E1, E2)>
1 Like

I'd like to reiterate the concern that making bare P mean some P in the same version that removes the implicit any P seems aggressively fast. This will be an additive change, and can be added to any later 6.x version. I don't think we should do this for 6.0.

3 Likes

I agree that there should be a version step between bare P becoming any P and some P becoming bare P. A simultaneous change is no transition at all.

I think it's important to distinguish "is this the right thing to do" from "when to time the change. I think this thread is probably jumping prematurely to the "when", whereas it is probably better to start with the "is it right". Bear in mind, this is a discussion thread, not even a pitch. I think the first question to answer is whether the default actually should be flipped. Then after that, it's worth looking at what the migration path might be.

That said, let's talk briefly about the timing part, while for now assuming that it is the right thing to do.

I think, when flipped around, this comment perfectly describes why it is potentially important to bundle these two change together, and that separating them will lead to inconvenience and confusion for developers.

Consider that, as Joe points out above, now we have opening a function that takes an existential argument is usually indistinguishable to the caller from a function that takes a generic argument.* And updating a function that takes an existential argument to one that takes a generic argument is usually not something that requires any changes to the function implementation.

What this means is that developers may find that many functions need no change when flipping the default for bare protocols. They compile today as any as the default, and they will compile tomorrow with some as the default.

If we separate mandating any from flipping the default, this means the migration will be in two stages:

  • First, developers need to go through any and some into their code when they migrate to Swift 6
  • Then, in a later release, they get to take the some out again.

For the first phase, the developer will need to decide what to do... should it be any or some? any might seem like the safe choice, but chances are some would be the better one. (Bear in mind, because we have not had opening all along, there are many functions that are out there written to take existentials because they were being passed existentials... something that's no longer necessary) If they choose to go the some route, they will be littering their code with a keyword only to allow them to taken it out again later.

By contrast, by combining the two moves together, a codebase that has a bunch of functions that take existentials, and that hasn't been touched since 5.7 meant it could switch those to generic functions, can just take the flip without needing to two-step insert some then be able to take it out again. That's really not a great experience for users.

Now this doesn't account for other cases, like functions that take an optional existential, which cannot just switch to some because opening won't work with optionals. I think some discussion should be had around how to tackle this kind of migration. Even without flipping the default, I think we'll want some tooling to help people analyze their codebases to help make the right choice about whether to add some or any, based on looking at callers. And there is a question of whether packages with public interfaces should have a different migration strategy to source code for closed systems where, even though you have frameworks with public functions

I'm also not addressing storage here, just arguments. But I think in a way that's an easy migration too: some is probably going to almost never be the right choice for a variable, except when it's initialized with type inference, so we proably will end up with a migration assistant for that which would just add the any in. The best practice here would be to encourage people to do it ahead of migrating to Swift 6, since the any keyword will have existed for at least 1.5 years prior to that happening.

11 Likes

I agree we shouldn't get too involved in debating "when should we do this?" when "should we do this at all?" is an open question, but it's possible IMO that each question would inform the other—e.g., it might be that flipping the default would be highly desirable for the sake of easing the transition, but reintroducing bare P after it has been fully purged from most active codebases wouldn't pull its own weight.

So, further on timing... :slightly_smiling_face:

I think this in particular:

is quite a strong case for investigating switching the default for bare P in Swift 6. If it's really the case that we could dramatically reduce the burden of the 'bare P' removal transition, that would be great.

That said,

it doesn't make a whole lot of sense to me to frame this as a "two stage" migration. We didn't frame it as a single-stage 'migration' when we introduced the if let x shorthand—it's just a new convenience that new code can make use of going forward.

The value in changing the meaning of bare P in Swift 6 isn't that it saves us a step, it's what you note above: that it (potentially) reduces the impact of the P to any P migration by a pretty significant margin. To properly evaluate the difference between the one-step and two-step approaches, I think we'll want to at least explore:

  • For what proportion of bare P uses would flipping the default completely obviate the need for the user to do any migration?
  • In cases where flipping the default doesn't 'just work', how much more difficult will the failures be to explain and/or fix?
  • Will we be able to confidently catch and diagnose during migration any situations where semantics might silently change, or alternatively, will such instances be so exceedingly unlikely that we need not worry about them?

This last item is particularly important—I agree with @xwu that a silent behavior change is more insidious than a new compiler error, so I think our confidence level should be quite high that such instances will (almost) never happen in real-world code.

7 Likes

I think that's a little different though. By mandating any, we are requiring that people uglify their code (whether they stick with any or switch to some), and then if we later flip the default, they will only then be able to beautify it again.

Now sure, the beautification is optional, just like with if let. But what's different is it's re-beautification because today, it looks better than it temporarily will have to.

3 Likes

Yeah, I take your point. I just think talking about it as a two-step migration misstates how it will actually be experienced by most users. It won’t be a “agh, now I have to change all my protocol types again?” when (if) the bare P syntax is brought back, it will be “ah, neat, now I can stop writing some all the time.” I think the stronger point is that flipping the default later potentially makes the migration (or, the first step) much more painful than necessary.

I also suppose I feel like the some P syntax is sufficiently lightweight and beautiful in its own right that I don’t think it’s all that bad if there’s a large number of lingering somes in a codebase that could be removed, eventually.

3 Likes

I don't disagree with you. I also don't disagree with @Ben_Cohen's argument — in isolation.

The problem here is that members of the compiler team are very close the changes and the semantics of the new some/any syntax. They know what it is, they're comfortable with it.

I predict that other developers further from the center of the universe (especially those who don't get tutorialized by visiting the Swift forums), when faced with the upcoming any changes, will have to spend some time grasping what the new some P and any P mean at all, let alone fathom an invisible change to bare P.

I predict that even developers who would accept the advice that a some-less P is what they should in general be using, will use it without really knowing why it's what they should be using — at least at first.

It seems to me there's an argument that requiring some, initially, will be a useful discipline for understanding, rather than a useless exercise in syntax.

I don't disagree with that, either. Also, we know that there's a large cadre of "completionist" developers out there — developers who, for example, choose to prefix their instance property names with self. although it isn't syntactically necessary. Not everyone is going to choose to eliminate unnecessary somes.

11 Likes

I think eliding some would be a step in wrong direction. We just have swift proposals and wwdc 2022 videos explaing that plain protocol (as constraint) and protocol as type are two different things and the any/some make that distinction more explicit and understandable.

After all that work and effort to make swift more clear and understandable, we go backwards and add more confusion again?

There’s been a few proposals around (like the if let) which optionally remove syntax just for the sake of removing syntax. I find this worrying. Those kind of things take away possibilities to make swift a easy to understand language.

We don’t want a language where developers make the right choice by accident (i.e. elided some) and then don’t understand how to do heterogenous version if they need it. We want people to make conscious choice between some/any and understand the difference between them.

20 Likes