Protocols are tricky to fully understand. 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:
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.
protocol Playable {
var position: Int { get set }
var duration: Int { get }
func play()
}
struct AudioFragment: Playable {
var position: Int
let duration: Int
func play() {
print("I am playing an audio fragment")
}
// implementation omitted
}
struct Movie: Playable {
var position: Int
let duration: Int
func play() {
print("I am playing a movie")
}
// implementation omitted
}
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.
func randomPlayable() -> Playable {
if Bool.random() {
return AudioFragment(position: 0, duration: 3600)
} else {
return Movie(position: 0, duration: 3600)
}
}
let playable: Playable = randomPlayable()
// Either will print "I am playing an audio fragment" or "I am playing a movie".
playable.play()
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
. An 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 Playable
.
Below we try to put both an AudioFragment
as well as a Movie
inside the same array. The compiler asks us to add [Any]
explicitly.
// Array is of type [Any]
let elements: [Any] = [
AudioFragment(position: 0, duration: 3600),
Movie(position: 0, duration: 3600)
]
type(of: elements) // Array<Any>
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.
// Array is now of type [Playable]
let playableElements: [Playable] = [
AudioFragment(position: 0, duration: 3600),
Movie(position: 0, duration: 3600)
]
type(of: playableElements) // Array<Playable>
// We can call play() on any element
playableElements.randomElement()?.play()
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 Any
.
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.
var movie = Movie(position: 0, duration: 3600)
skipForward(playable: &movie)
print(movie.position) // 10
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 Playable.
// Runtime version
func skipForward(playable: inout Playable) {
playable.position += 10
}
// Compile-time version
func skipForward<T: Playable>(playable: inout T) {
playable.position += 10
}
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 Playable
.
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.
// This...
func skipForward<T: Playable>(playable: inout T) {
playable.position += 10
}
// ...gets specialized to multiple functions.
func skipForward(playable: inout AudioFragment) {
playable.position += 10
}
func skipForward(playable: inout Movie) {
playable.position += 10
}
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.
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
. Note that Equatable
references Self
inside the ==
function.
public protocol Equatable {
static func == (lhs: Self, rhs: Self) -> Bool
}
Let’s say that we want to check if all elements inside an array are equal with a function called areEqual
.
areEqual(elements: [1, 1, 1, 1]) // true
areEqual(elements: ["Lawnmower", "Sheep"]) // false
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 Equatable
.
// This won't work, each element could be a different thing.
func areEqual(elements: [Equatable]) -> Bool {
guard let first = elements.first else {
return false
}
return elements.dropFirst().allSatisfy { $0 == first }
}
This will give the classic error: “Protocol can only be used as a generic constraint because it has Self or associatedType requirements”
Since the 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 Equatable
.
// Constraining is allowed
func areEqual<T: Equatable>(elements: [T]) -> Bool {
guard let first = elements.first else {
return false
}
return elements.dropFirst().allSatisfy { $0 == first }
}
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 Equatable
.
// Won't work.
areEqual(elements: [1, "Scary clown"]) // error: in argument type '[Any]', 'Any' does not conform to expected type 'Equatable'
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 Swift in Depth, chapter 13.
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 ==
function.
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.
enum Marsupial: Equatable {
case kangaroo
case wallaby
case opossum
case bandicoot
case wombat
}
// We did not need to implement the == function!
Marsupial.kangaroo == Marsupial.wallaby // false
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.
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 Encodable
and Decodable
, found [inside the Swift source][codable]. They allow us to encode and decode our types. For instance, turning a dictionary into a JSON and back again.
let dict = ["Bonjour!": "Hello"]
// Encoding
let jsonData = JSONEncoder().encode(dict)
let rawJsonString = String(data: jsonData, encoding: .utf8)
print(rawJsonString) // Optional("{\"Bonjour!\":\"Hello\"}")
// Decoding
let decodedDict = JSONDecoder().decode([String: String].self, from: jsonData)
print(decodedDict) // ["Bonjour!": "Hello"]
If we look at the Encodable
and Decodable
protocols, they merely contain one method each.
public protocol Encodable {
func encode(to encoder: Encoder) throws
}
public protocol Decodable {
init(from decoder: Decoder) throws
}
Notice how both Encodable
and Decodable
do not have any compile-time constraints. We can tell because there are no associatedtype
or Self
keywords used. Also the types that they use—namely, Encoder
and Decoder
—don’t have any compile-time constraints. Also, neither Encodable
or Decodable
inherit from another protocol with compile-time constraints.
Together they make up Codable
. Codable
may seem like a single protocol, but it’s a composition of two protocols.
public typealias Codable = Encodable & Decodable
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.
struct Container {
let data: Codable
}
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 Codable
.
// Won't work!
// We try to make Container Codable too.
struct Container: Codable {
let data: Codable
}
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. Codable
is 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 Encodable
.
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 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 T
.
struct Container<T: Codable>: Codable {
let data: T
}
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.
let container = Container(data: "Hello")
let jsonData = JSONEncoder().encode(container)
// Check if it worked
let rawString = String(data: jsonData, encoding: .utf8)
print(rawString) // Optional("{\"data\":\"Hello\"}")
Become a Swift pro
Updated for Swift 5!
Check out Swift in Depth and master tough concepts; Such as Generics, Functional Programming concepts, and Protocol-Oriented Programming.
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.
Written by
Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.