Combining Protocols

Combining Protocols

You know the basics of defining and implementing protocols, and you’ve seen some examples of extending protocols or combining protocols with inheritance or composition. Along with class inheritance, you now have a wealth of options for designing your app’s data model. But how do you decide which to use?

Extending a Protocol

Sometimes, it turns out that most types that conform to a protocol have the same implementation of a method, initializer, subscript, or computed property. Instead of duplicating code, you can provide a default implementation in an extension of the protocol. If a conforming type provides its own implementation of a required method or property, that implementation will be used instead of the one the extension provides.

When you define a protocol extension, you can use a where clause to specify constraints that conforming types must satisfy before the methods and properties of the extension are available. You’ll see an example of this soon.

You can also implement methods in an extension that aren’t required methods in the protocol. This can have surprising results, which you’ll learn about in this lesson.

Protocol Inheritance vs. Class Inheritance

Classes have inheritance; structs and enums don’t. But all types can implement protocols. So when you’re trying to model objects that have some properties or functionality in common, do you change your structs to classes or use protocol inheritance? Or something else? Here are some differences you might consider:

  • A class can inherit from at most one other class; a protocol can inherit from one or more other protocols.
  • A class models an object with properties and methods. Class inheritance models “is-a” relationships: A Laptop is a Computer is a Product.
  • A protocol models a capability. To implement a protocol, an object demonstrates its ability to perform the protocol’s required tasks.

Class-Only Protocols

Every class implicitly conforms to AnyObject. If a protocol inherits from AnyObject, only classes can implement it.

protocol MutableLocalizable: Localizable {
  mutating func change(to language: Language)
}

protocol UIKitLocalizable: AnyObject, Localizable {
  func change(to language: Language)
}

Note: The code for this page and the next are in Protocols.playground.

Classes and structs can conform to MutableLocalizable. To implement change(to:), a struct must mark it as mutating. Because only classes can conform to UIKitLocalizable, you don’t need to mark change(to:) as mutating. You can also limit a protocol to only subclasses of a specific class.

protocol LocalizableViewController where Self: UIViewController {
  func showLocalizedAlert(text: String)
}

Inheritance vs. Composition

Use protocol inheritance to refine capability. Protocol inheritance is a very strong relationship that can reduce the reusability of the inheriting protocol, if you try to inherit a capability that isn’t closely related. Some standard library examples of protocol inheritance are:

  • Codable inherits from Encodable and Decodable.
  • Comparable inherits from Equatable. In addition to being able to determine whether two instances are equal, conforming types must be able to determine whether one instance is greater than or equal to another instance.

Use protocol composition to add unrelated capability. Composition is much more flexible than inheritance but can quickly reduce your code’s readability. Fortunately, typealias is your friend!

This example is from John Sundell’s Combining protocols in Swift. You create a protocol — DiskWritable — to be implemented by types that can be written to disk:

protocol DiskWritable {
  func writeToDisk(at url: URL) throws
}

You want to extend this protocol with a default writeToDisk(at:) for Encodable types. One way is to have DiskWritable inherit from Encodable:

protocol DiskWritable: Encodable {
  func writeToDisk(at url: URL) throws
}

extension DiskWritable {
  func writeToDisk(at url: URL) throws {
    let encoder = JSONEncoder()
    let data = try encoder.encode(self)
    try data.write(to: url)
  }
}

However, there’s a problem with this: protocol DiskWritable: Encodable tightly couples DiskWritable to Encodable, which makes DiskWritable much less flexible. Instead, write a constraint for the extension:

protocol DiskWritable {
  func writeToDisk(at url: URL) throws
}

extension DiskWritable where Self: Encodable {
  func writeToDisk(at url: URL) throws {
    let encoder = JSONEncoder()
    let data = try encoder.encode(self)
    try data.write(to: url)
  }
}

To use this writeToDisk(at:), a type must conform to both DiskWritable and Encodable:

struct TodoList: DiskWritable, Encodable {
  var name: String
  var items: [String]  // simplified Item
}

You can use typealias and the composition operator & to make it clear that this feature exists:

typealias DiskWritableByEncoding = DiskWritable & Encodable
struct TodoList: DiskWritableByEncoding {
  var name: String
  var items: [String]
}

Extending a Protocol

And now, back to protocol extensions and the surprising results you can get when you implement methods that aren’t required in the protocol. The following example is from Expert Swift.

The Greetable protocol has one required method: greet(). You write a default implementation of greet() in an extension, then add a new method, leave(). It’s not declared as a requirement of Greetable, but every conforming type can access it.

protocol Greetable {
  func greet() -> String
}

extension Greetable {
  func greet() -> String {
    return "Hello"
  }

  func leave() -> String {  // not a requirement of Greetable
    return "Bye"
  }
}

Now, you declare GermanGreeter — the extension defines default implementations, so GermanGreeter doesn’t need to implement either method:

struct GermanGreeter: Greetable { }

When you create an instance of GermanGreeter and call greet() and leave(), you’re calling the default methods:

let greeter = GermanGreeter()
print(greeter.greet())  // Hello
print(greeter.leave())  // Bye

Now, write German implementations for both methods:

struct GermanGreeter: Greetable {
  func greet() -> String {
    return "Hallo"  // one letter different from Hello
  }

  func leave() -> String {
    return "Tschüss"
  }
}

When you run the code again, you get the German words:

let greeter = GermanGreeter()
greeter.greet()  // Hallo
greeter.leave()  // Tschüss

OK, here comes the surprise. Declare a value of type Greetable and initialize it to GermanGreeter(), then call greet() and leave():

let greeterX: Greetable = GermanGreeter()
greeterX.greet()  // Hallo
greeterX.leave()  // Bye

You call the required method greet() that’s implemented in GermanGreeter, but the leave() method is the extension’s default method. The reason for this is buried deep in Swift’s inner workings.

Static & Dynamic Dispatch

Like everything in your code, your functions have an address in your app’s memory. When your code calls a function, Swift jumps to that address to dispatch (start executing) the function. But there are two ways to dispatch a function, depending on how it’s stored: static dispatch and dynamic dispatch.

TL;DR: Non-final class methods use dynamic dispatch; struct methods that aren’t part of a protocol use static dispatch. A protocol’s required methods use dynamic dispatch, but non-required methods in protocol extensions use static dispatch. Both leave() calls use static dispatch, based on the object’s type. Because greeter is of type GermanGreeter, leave() calls the struct’s method. But greeterX is of type Greetable, so leave() calls the extension’s method.

Static dispatch happens when Swift knows the function will never change — global functions and methods declared in structs as well as methods on final classes — so its address will never change. Finding it is very fast — Swift loves when things don’t change!

Once you introduce class inheritance, you can store a method called on a non-final class instance in several places: inside the class, any of its parent classes, an extension, or even a protocol extension. Non-final class methods use dynamic dispatch. Swift looks up the correct address in a witness table: As the compiler goes through your code, it creates a table for each class, with two columns: one for an offset in the table and one for the function at that offset. Each function in the class is stored in the table, which is stored in your working memory. A subclass gets a copy of its parent’s table and replaces the rows of any method it overrides. When Swift encounters a method call at runtime, it knows which offset in the table corresponds to that method.

This allows dynamic changing of the implementation of a method with the same name, allowing features like inheritance, polymorphism, and even protocols. But these features come at a cost. Calling functions from table rows adds a constant overhead for each function call.

Dispatching protocol methods is like how classes work. Every type that implements a protocol gets its own protocol witness table, with a row for each member of the protocol (the methods and variables declared as the protocol requirements). At runtime, Swift looks up the correct function in the protocol witness table and calls it.

See forum comments
Download course materials from Github
Previous: Protocols - Introduction Next: Type Erasure