Reasoning about protocols
In this article, I’d like to clear up some confusion that may arise when dealing with Swift’s protocols.
Let me preface this by saying that I think the engineers of Swift do a tremendous job at making sure that Swift hides complexities from us as developers. We’re dealing with a high performing, ergonomic language, and yet when developing in Swift, we rarely have to worry about low-level mechanics, such as deciding whether to store data on the stack or heap, let alone dealing with pointers.
As much as I love Swift, I feel that protocols have been made “too ergonomic” and are coated with too much syntactic sugar, making it a bit hard to predict the behavior of protocols behind the scenes. In this article, I’m dusting off some of this syntactic sugar, and I aim to unveil some internals that we have to be aware of when dealing with protocols.
We can reason about protocols better by looking at them in four different ways:
- Using protocols at runtime
- Using protocols at compile-time
- Protocols with compile-time constraints
- Synthesizing protocol implementations and its compile-time impact
Dynamic dispatch with protocols
If we’re using protocols as types, then we’re using dynamic dispatch. Meaning that we are working with protocols at runtime.
For instance, let’s say we have a
Playable protocol, indicating a piece of audio or video that we can play. It has a
duration property and a
position property, indicating the current position of the media that we’re listening to or watching. Then, we’ll implement this protocol for a
Movie struct and a
AudioFragment struct, both resembling a piece of media that we can play.
We can use this protocol to make use of dynamic dispatch. For instance, we can have a method that decides which
Playable element to return from inside a function body.
What’s important to note is that the method itself decides what to return at runtime—this will be important later. We can’t know at compile-time whether we’re getting a movie or a song.
Another way to see dynamic dispatch in action is using
Array can only store a single type, but if we want a heterogeneous array, we will end up with
Array<Any>, or the idiomatic
[Any]. However, we can make it explicit that the types inside an array are of type
Below we try to put both an
AudioFragment as well as a
Movie inside the same array. The compiler asks us to add
However, since we know what we’re dealing with, we can make the array of type
[Playable] so that we can call
play() on any element.
By indicating that we have an array of
Playable types, we can mix and match multiple types inside the array using dynamic dispatch without resorting to
Using protocols as a type versus generics
Next, we’ll introduce a
skipForward function, which allows us to skip the
Playable type forward by ten seconds, similar to quick jumping a Youtube video or a Netflix show.
We can, for instance, create a
Movie and pass it to the
skipForward function, making it jump forward by 10 seconds.
We’ll compare two variations of the same function, one where we pass a
Playable as a type, and one where we pass a generic type
T constrained to
These functions are very similar but with a significant difference. When we dynamically pass a
Playable as a type, we end up with one function that accepts any type conforming to
The generic function, however, works a bit differently. At compile-time, the compiler will check which types are using the generic
skipForward function and specializes this to concrete methods.
Turning generic code into specialized code is a process called monomorphization, where polymorphic code—such as code with generics—get turned into concrete code at compile-time. You may worry about a larger binary size, also known as code bloat. However, some clever tricks are happening under the hood to limit the actual code generation to minimize large binaries.
On top of this, Swift can even perform optimizations because of compile-time knowledge—more info here if you want to know more about the behind-the-scenes workings of Swift’s generics.
Because we don’t need dynamic dispatch here, I recommend using the generic version, giving us compile-time knowledge of our code and performance optimizations.
Protocols with compile-time constraints
If we are working with a protocol with an associated type, or one that uses
Self, then we can’t use them at runtime—with some exceptions aside.
A pervasive protocol with these compile-time constraints is
Equatable, which you can find in the Swift standard library. Note that
Self inside the
Let’s say that we want to check if all elements inside an array are equal with a function called
Next, we’ll create the
areEqual function. As a first attempt, we naively use
Equatable as a type. However, the compiler won’t allow dynamic dispatch because of
This will give the classic error:
“Protocol can only be used as a generic constraint because it has Self or associatedType requirements”
Equatable protocol has
Self requirements, we can only use it to constrain a generic. Here we write
areEqual again, but this time we’ll introduce a generic
T which we constrain to
This time, the compiler is pleased. Since we use generics, all elements will have to be of the same type, such as all strings, or all integers.
On the other hand, we can try to mix and match the elements inside the array, but then we have an array of
Any which doesn’t conform to
As an exception, we can use protocols with associated types and
Self requirements dynamically. But, that requires something called type erasure. You can read more about it in my book.
Lastly, the Swift compiler can synthesize implementations for us for specific protocols. Regardless of compile-time constraints on a protocol. First we’ll look at
Equatable, and then
Codable, which does not have compile-time constraints.
Because the compiler can synthesize certain methods for us, if we make an enum conform to
Equatable, we do not need to write the implementation of the
We’ll introduce an enum called
Marsupial and make it conform to
Equatable. Notice how we are not writing the implementation for
Equatable, yet we can check if two of these enums are equal.
(Note that no cases on this enum have asssociated values, making it easier to get the
Equatable implementation for free.)
It’s easy to conflate synthesizing with generics. With generics, we define a single element, function, or method, and the compiler generates variants for us for multiple types.
With synthesizing, the compiler generates the implementation of a method for us, preventing us from writing a method or function altogether. What synthesizing and generics have in common, is that both happen at compile-time.
Synthesizing and Codable
Since synthesizing also works on protocols that we can use at runtime, we can get into some tricky scenarios. Let’s keep clearing up some more confusion that can arise when dealing with these protocols.
Two other protocols that allow for synthesizing their implementations is
Decodable, found inside the Swift source. They allow us to encode and decode our types. For instance, turning a dictionary into a JSON and back again.
If we look at the
Decodable protocols, they merely contain one method each.
Notice how both
Decodable do not have any compile-time constraints. We can tell because there are no
Self keywords used. Also the types that they use—namely,
Decoder— don’t have any compile-time constraints. Also, neither
Decodable inherit from another protocol with compile-time constraints.
Together they make up
Codable may seem like a single protocol, but it’s a composition of two protocols.
Since there are no compile-time constraints, we can use these protocols dynamically. For instance, let’s say we have a struct called
Container, we can give it a
Codable property without resorting to generics.
We can create
Container without a hassle because
Codable can be used dynamically. But there is a subtle problem. What if we want
Container to be
Codable too? We might naively try to make
Container conform to
We get an error without any hint of how to solve the problem.
error: cannot automatically synthesize ‘Encodable’ because ‘Codable’ (aka ‘Decodable & Encodable’) does not conform to ‘Encodable’
This error looks confusing.
Encodable, but the compiler says it does not conform to it. This may sound strange, but it’s because we are using
Codable as a type, and the type
Codable (not the protocol definition of
Codable) does not conform to
It becomes a bit meta, you may sometimes read about how “protocols do not conform to themselves”. Don’t worry if this sounds confusing, we will fix the problem right away.
Pop quiz: Do you know how to make
Container conform to
Making Container Codable
We’ve seen how implementations are synthesized at compile-time. Our problem is that
data is of type
Codable and therefore used dynamically.
The solution here is to move to compile-time, which we can do by using generics. Instead of using
Codable at runtime for the
data property, we define a generic called
T, constrain it to
Codable, and make
data of type
We have satisfied the compiler gods and our code works again. Thanks to generics, at compile-time, the
Codable implementation of
Container can be generated, and we’re free to encode or decode
Container. To give our code a test-run, we can load up a
Container with a string, and encode this to JSON.
We ran into the problem with
Codable because we want compile-time behavior (synthesizing) on a protocol that allows itself to be used dynamically. It’s one of those “things you just have to know” quirks in Swift.
Understanding that protocols can have both runtime and compile-time behavior will make developing Swift much more pleasant.
However, as powerful as protocols are, we need to be aware of these hidden properties of protocols. Without diving into a protocol, we can’t see whether a protocol has compile-time constraints or gets synthesizing behavior, which sometimes makes it quite hard to evaluate our code, even more so when protocols inherit from other protocols. Often this means we will have to look into each protocol to decide on the best approach.
Thanks for reading, I hope you enjoyed the article!
I'm Tjeerd (@tjeerdintveen), an iOS freelancer, avid Swift fan, and author.