Reasoning about protocols

Tjeerd in 't Veen

Tjeerd in 't Veen

— 15 min read

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:

  • 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.

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.

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.

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.

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. 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.

Synthesizing implementations

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.

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 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?

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 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.

Conclusion

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.