Swift Generics Evolution
Earlier this week, Joe Groff of the Swift Core Team published a massive discussion post on the Swift forums. It discussed a lot of possible changes to the way that generics work in the Swift language, and kicked off the process with a link to SE-244, a proposal to introduce some features around function return values. The post as a whole was an absolutely fascinating read, and made a really compelling case for some powerful new ideas that might come to Swift.
In discussing this post with some colleagues, though, it’s become clear that the intended audience is a little more embedded into the theory and technical details of programming language evolution than your average Swift developer. There’s nothing wrong with that — it’s reassuring to know that the folks driving changes in Swift have a solid background in language design, and that they’re thinking about all manner of hard problems in order to make our lives easier. However, it makes me worry that people might be missing out on a truly exciting conversation about what might be coming in a future Swift version.
To that end, this post aims to walk through some of the proposals from Joe’s document, explaining the syntax and offering examples of how the changes to generics might look in practice. We’ll pick up a couple technical terms in a practical setting, and wrap up with some details about the open Swift Evolution proposal(s) being considered.
Ideas Taking Shape
Before we dig into the proposed changes, let’s establish an example right up front. We want something that’s complex enough to grow and change, but we should avoid the mental overhead of something as complex as Collection, with all its Iterators and Elements and other protocol conformances.
Instead, I’ll borrow an example from SE-244. Let’s start with the very basics of a drawing or diagramming app; we can imagine that we have a blank canvas, and the means to put different shapes, lines, or even text in different places. In true protocol-oriented programming fashion, instead of building a huge class hierarchy for these different elements, let’s start with a protocol.
protocol Shape {
// Render the shape into the current graphics context
func draw()
}
struct Rectangle: Shape {
var width: Float
var height: Float
func draw() { … }
}
struct Circle: Shape {
var radius: Float
func draw() { … }
}
The Shape of the World
This is enough to get us started with some of the existing generics features already in Swift 5. For example, we might have a helper inside a Canvas object that draws a single Shape at a point:
func render<T: Shape>(_ shape: T, at point: Point) { … }
We might have another helper that adds a new Shape of some type to our Canvas, returning the new instance:
func addShape<T: Shape>() -> T
This is probably familiar ground to Swift developers who have used generics even
a little bit. The syntax — the way we type out the function definitions, the use
of angle brackets, and the conventional use of T
for a generic type — is also
probably recognizable to folks familiar with generics in other languages, like
Java or C♯.
These are both examples of type-level abstraction. Each of these functions
has a placeholder type T
; all we know is that T
has to conform to Shape
.
Each call site then gets to pick what concrete type is bound to T
, making
these functions very flexible and powerful in a variety of situations.
let circle: Circle = addShape()
// T is inferred to be Circle by the type decoration
render(circle, at: Point(x: 10, y: 10))
// T is inferred to be Circle by the first argument type
All this happens at the function (or type) level. By contrast, when we start dealing with individual variables, we get into the opposite: value-level abstraction. Now we’re not concerned with making general statements about the types that can be passed into or out of a function; instead, we’re only worried about the specific type of exactly one variable in one place.
var shape: Shape = Rectangle()
// shape is of type Shape, even though we know it's "really" a Rectangle
shape = Circle()
// The type of shape is still Shape, even though it's now "really" a Circle
In this example, we would only be able to use Shape methods on the shape
variable, no matter what type it “really” is at any given moment. Properties
like shape.width
or shape.radius
are unavailable, even when the shape is
really a Rectangle or Circle, because we’ve declared that the variable’s type is
constrained only by the Shape protocol.
This leads us to perhaps the most difficult idea in the current generics system: existential types. So far, it seems like we’ve had only three types declared: the Shape protocol, and the concrete Circle and Rectangle, which both conform to that protocol. However, in reality, a fourth type snuck into this last example when we weren’t looking. This type was an existential for Shape.
In can be helpful to think of existential types like wrappers or boxes for other
types. When we declared the shape
variable, and gave it the type annotation :
Shape
, what Swift did was set up a variable that could hold on to any concrete
type conforming to the Shape protocol. We filled that variable right away with
a Rectangle, and later replaced it with a Circle, but in both cases the type of
the value was that Shape existential. (Certain bits of the Swift language, like
type(of:)
or casting with as?
, can work through existentials to get at the
underlying types.)
This idea — that there’s an extra abstracted type floating around in our code — can be disconcerting at first. This reaction is often compounded by the fact that existentials look very, very similar to protocol types at first. This is what Joe means when he writes:
Also, protocols currently do double-duty as the spelling for existential types, but this relationship has been a common source of confusion.
The best way to distinguish a protocol type from an existential type is to look at the context. Ask yourself: when I see a reference to a protocol name like Shape, is it appearing at a type level, or at a value level? Revisiting some earlier examples, we see:
func addShape<T: Shape>() -> T
// Here, Shape appears at the type level, and so is referencing the protocol type
var shape: Shape = Rectangle()
// Here, Shape appears at the value level, and so creates an existential type
Now that we’ve got a good handle on existentials, we can start to dig into the core of Joe’s post: how things might change in the future.
Any Shape You Like
Existentials have long been a part of Swift, but it’s rare that developers have to confront them consciously. In fact, that was one of the goals of the current design:
We gave existential types an extremely lightweight spelling, just the bare protocol name … partially out of a hope that they would “just work” the way people expect; if you want a type that can hold any type conforming to a protocol, just use the protocol as a type, and you don’t have to know what “existential” means or anything like that.
However, there are a few sharp edges that resulted from this decision, so one of
the goals laid out in the forum post is to help clarify the difference between a
protocol and its existential. The initial proposal involves introducing a new
keyword any
into the language. This keyword would be required to appear when
declaring a variable of existential type, so it would become obvious when the
usage differed from the protocol type.
func addShape<T: Shape>() -> T
// No change, since Shape is used as a the protocol type here
var shape: any Shape = Rectangle()
// The new keyword `any` distinguishes a variable of existential type
This keyword follows in the footsteps of languages like Rust, which uses
dyn
in a very similar way. It’s interesting to note that the forum post
explicitly argues against dyn
or auto
, a C++ism, for this feature — but that
some of the early commentary has raised questions about the choice of any
, so
the exact syntax might be a point of contention when this change comes to Swift
Evolution.
Hidden Shapes
Beyond changes to existentials, Joe’s post raises several possibilities for
improvements in the type-level generics system as well. (In fact, the post
offers the any
keyword last, and I just flipped the order around here.) One
of the most exciting changes up for debate is the ability to have a method
return a value of a generic type chosen by the method itself.
This is another one of those subtle distinctions that isn’t immediately obvious. After all, methods can already have generics in their return types, right? We even had one earlier:
func addShape<T: Shape>() -> T
// Returns T, a generic type conforming to Shape
The trick with this kind of type-level abstraction is that it’s the code calling
the function that gets to pick the type. That is, the implementation of
addShape()
doesn’t get to choose just any type conforming to Shape in its
return value; it has to work with a real type coming from each call site.
For this particular function, that’s probably fine — we might expect our hypothetical Canvas to know how to construct and add each concrete kind of Shape. However, there are other cases where we might want the function implementation to be the one choosing the concrete type, not the caller. For example, let’s hypothesize a function that wants to return a Shape that is big enough to encompass all the other existing Shapes. This kind of thing can be useful when drawing a background, for example, or for visually grouping existing shapes together.
There are a couple ways we could write this. The first and most obvious is to return the exact type that we use for the background shape.
func allEncompassingShape() -> SpecialGiantShape
However, that means exposing some implementation details of
allEncompassingShape()
, including a type declaration for the concrete shape
that’s returned. While this might be OK in some circumstances, there are
definitely times when — for one reason or another — we don’t want to have to
expose those types. Let’s say this is one of those times, and having callers
know about the existence of SpecialGiantShape is undesirable. Instead, we
can try to cover it up with generics.
func allEncompassingShape<T: Shape>() -> T
The trouble is, just like addShape()
above, this function’s return type is
picked by the caller — and that might not be right! If the function wanted to
continue using SpecialGiantShape, but was declared with generics this way, a
caller could produce a type mismatch for the result value.
let background: Circle = allEncompassingShape()
What we want is a middle ground that lets allEncompassingShape()
hide the
specifics of what type it’s returning, but also not let the caller have much say
in the matter.
The proposal on the table is to add something that behaves a lot like a generic type, but lets the implementation choose the concrete type, and only inform the caller about some protocols that type conforms to. The first syntax proposal for this feature has colloquially been called “reverse generics.” It introduces a new place that angle-bracketed generic types could appear: to the right of the return arrow in a function definition.
func allEncompassingShape() -> <T: Shape> T
In this situation, it’d be allEncompassingShape()
that picks T
, rather than
the caller. Together with the any
keyword from before, we might stash the
result of calling this function into a variable with existential type, or let
Swift infer that for us.
let background: any Shape = allEncompassingShape()
let otherBackground = allEncompassingShape() // still `any Shape`
The reason for the label “reverse generics” is that the flow of information is backwards from the existing system. Where right now, the caller binds generic types as it calls a function, the proposal would have the function itself bind the return types and pass concrete values back out.
Angling for Change
This sort of reverse-generics idea is already, on its own, quite powerful. It would fill in what Joe calls a “hole in the feature matrix for method API design” by allowing a new kind of abstraction, which in turn allows framework and library authors much more flexibility in their designs. The downside, though, is that it exacerbates one of the common complaints about Swift syntax: the prevalence of angle brackets in generics syntax.
So far, our examples have been fairly tame; we’ve had a function with a generic argument, or generic return, but not more than one of either. In practice, though, it’s easy to let generics get out of hand. We could imagine that our graphics program, like most, offers the ability to combine or “union” shapes. If we wanted this to work with arbitrary pairs of shapes, we’d need to write a function that accepted multiple generic parameters — and if we want the function to control the kind of Shape it returns, we’d want this new “reverse generics” approach to defining the return type.
func union<T: Shape, U: Shape>(_ leftShape: T, _ rightShape: U) -> <V: Shape> V
Already, the list of one-letter abbreviations and multiple angle-bracketed declarations are a lot to handle, and it gets even worse when the protocol in question has associated types. Let’s take a look at an example by extending our original Shape protocol somewhat.
Early on, we mentioned the possibility for our canvas to have regular shapes,
but also text and images. Instead of a plain draw()
method in the protocol,
what if each of these kinds of Shape had their own Renderer, which was
responsible for doing the drawing? That way, we could use a text view or related
Core Text class to render text, some bitmap helpers to render raster images, and
another library to handle vector shapes. Revisiting our very early Shape
examples, we might refactor to express this to Swift.
protocol Shape {
associatedtype Renderer
var renderer: Renderer { get }
}
Now, going back to our union(_:_:)
function, we might make the argument that
you should only be able to combine shapes that would use the same type of
Renderer. After all, you can blend bitmap images, or combine vector paths, but
it’s not immediately obvious how to treat the union of text and a vector image.
(There are some answers, but those are beyond the scope of a Swift type system
discussion.) Let’s try to express this using the angle bracket syntax.
func union<T: Shape, U: Shape>(_ leftShape: T, _ rightShape: U) -> <V: Shape> V
where T.Renderer == U.Renderer, U.Renderer == V.Renderer
This just keeps getting more and more extensive. Not to worry, though — the discussion post has some proposals to ease this syntax as well, starting by cleaning up those angle brackets.
Much like we were able to help distinguish existentials with a new keyword
any
, the idea here is to use a related keyword to smooth over the need to
declare a single-letter generic type with a constraint. And since we want to
communicate that the function will get some single type at call time, the
immediate keyword proposal is some
. This would make our generic-return
function declarations shorter right away.
func allEncompassingShape() -> some Shape
// The basic case: replace `<T: Shape> T` with the less angular `some Shape`
func union(_ leftShape: some Shape, _ rightShape: some Shape) -> some Shape
// `some` can appear multiple times in a declaration, meaning a new generic type each time
The one last piece of information we’re still missing is the desire to have all
the shapes involved in union(_:_:)
have the same associated Renderer type.
This is where some of the most fervent discussion has happened following Joe’s
original post, and where there are a few syntax options up in the air. One is to
use the argument labels in place of the single-letter generic types, but leave a
lot of the constraint syntax the same. The special keyword return
would be
able to stand in for the generic return type in this case.
func union(_ leftShape: some Shape, _ rightShape: some Shape) -> some Shape
where type(of: leftShape).Renderer == type(of: rightShape).Renderer,
type(of: rightShape).Renderer == type(of: return).Renderer
Some feedback after the post proposed refining the constraint syntax to imply
the type(of:)
expressions, avoiding boilerplate where the compiler could
figure it out.
func union(_ leftShape: some Shape, _ rightShape: some Shape) -> some Shape
where leftShape.Renderer == rightShape.Renderer, rightShape.Renderer == return.Renderer
This almost comes full circle to the existing syntax, but with some
standing
in for angle brackets and single-letter generic types — a readability win.
However, there’s still a concern about the constraints being too far away from
the original types. How obvious was it to you that the some Shape
being
returned from the function had a limitation on its Renderer
, by the time we
got to the end of the declaration?
To help keep these constraints closer to their related types, one idea is —
believe it or not — to reintroduce angle brackets. If we name a generic type for
the Renderer that’s involved all the way through this function declaration, we
can add back constraints on each some Shape
as it appears.
func union<T>(_ leftShape: some Shape<.Renderer == T>,
_ rightShape: some Shape<.Renderer == T>)
-> some Shape<.Renderer == T>
This syntax has all the advantages of the some
keyword, which eliminates angle
brackets in functions with simple generic arguments or return types. At the same
time, it has all the flexibility of the current where
-clause constraint
system, since it can make rules about associated types right where the generics
are used.
A Familiar Shape
After this post was published, some eagle-eyed readers asked how returning some
Shape
really helps us over the current state of affairs. After all, we can
already write something incredibly similar, just without the some
keyword.
func allEncompassingShape() -> Shape
// Legal Swift 5 syntax
The difference is twofold. Under the new proposal, there would be a change both in what type comes back and in what protocols are eligible. Let’s tackle them one at a time.
Remember that right now, we have a choice between two syntaxes: returning plain
old Shape
or returning T
, a generic type chosen by the caller. The first
produces a value with a Shape existential type, and the second produces a value
with a known concrete type that the function has no say over. If you’re going to
write a function like this today, either you have to be able to accept that your
caller picks the concrete type and return T
, or you have to accept the limits
of existential types.
One of these limits is probably very familiar to Swift developers who have spent
any extended amount of time with generics: in Swift 5, a function cannot return
a protocol existential value if that protocol has associated types. If we worked
with the enhanced Shape
protocol above, including its Renderer
associated
type, then we’d actually get an error trying to write our function.
func allEncompassingShape() -> Shape
// Error: protocol 'Shape' can only be used as a generic constraint
// because it has Self or associated type requirements
The other limitations are detailed at length in Joe’s post; they primarily
center around this kind of type system restriction, plus some deficiencies when
dealing with multiple related values of the same existential type. (Very
briefly: what happens if we call allEncompassingShape()
multiple times, then
try to relate their renderer
s? Can we be assured they’re of the same type?)
This is the hole that some Shape
tries to fill between the two existing
syntaxes: it lets the function, not the caller, pick the concrete type, and it
dodges some holes in the current state of affairs with existentials. If we were
to lay out all the choices, including the new proposal, it might look something
like the following.
func allEncompassingShape() -> Shape
// Legal now, but Shape cannot have associated types, and there are other
// practical concerns that might make this difficult in large systems.
func allEncompassingShape() -> any Shape
// A potential future wording of the above, with all the same restrictions and pitfalls.
func allEncompassingShape<T: Shape>() -> T
// Legal now, but the caller gets to pick the actual concrete type bound to T,
// which might not be what the implementation wants.
func allEncompassingShape() -> <T: Shape> T
// Future proposal that both avoids the restrictions on existentials and lets
// the implementation pick the concrete type bound to T.
func allEncompassingShape() -> some Shape
// A nicer wording of the "reverse generics" return; again, allows the
// implementation to pick the concrete type, and avoids existential problems.
Anyone interested in more details can check out the Twitter thread ending here, where various smart people hash out the differences at length. Thanks to all involved for bringing this topic further under the microscope.
The Final Shape
Phew! That was a lot to tackle, and we even glossed over some of the most complex or unusual edge cases of the original post. Nevertheless, there’s the kernel of some incredibly powerful ideas throughout these proposals; if the Swift language adopted even half of what’s being discussed, I’d be thrilled.
That process has actually already gotten started, with formal review in progress
on SE-244: Opaque Result Types. This proposal covers the “reverse
generics” idea and some
keyword in return types. If adopted, it would give us
the ability to return a concrete type hidden from the caller, indicating only
that the returned value conforms to some protocol(s). It’s authored by Joe
Groff, who gave us the discussion post summarized here, and Doug Gregor, the
author of the original “Generics Manifesto” for Swift years ago. I’m truly
excited to see where it goes.
To wrap up, I’d like to reiterate how grateful I am for Swift. The language already felt like a leap forward shortly after it was introduced, and the rapid pace of growth — even if sometimes challenging to keep up with — has made it easier and easier to develop truly great apps. It’s amazing to see the core team continue working on major changes in such a public way. Here’s to another five years!
Sincere thanks to the members of the Seattle Xcoders community for feedback on an early revision of this post.