Protocol-oriented programming & generics

View on TensorFlow.org Run in Google Colab View source on GitHub

This tutorial will go over protocol-oriented programming, and different examples of how they can be used with generics in day-to-day examples.

Protocols

Inheritance is a powerful way to organize code in programming languages that allows you to share code between multiple components of the program.

In Swift, there are different ways to express inheritance. You may already be familiar with one of those ways, from other languages: class inheritance. However, Swift has another way: protocols.

In this tutorial, we will explore protocols - an alternative to subclassing that allows you to achieve similar goals through different tradeoffs. In Swift, protocols contain multiple abstract members. Classes, structs and enums can conform to multiple protocols and the conformance relationship can be established retroactively. All that enables some designs that aren't easily expressible in Swift using subclassing. We will walk through the idioms that support the use of protocols (extensions and protocol constraints), as well as the limitations of protocols.

Swift 💖's value types!

In addition to classes which have reference semantics, Swift supports enums and structs that are passed by value. Enums and structs support many features provided by classes. Let's take a look!

Firstly, let's look at how enums are similar to classes:

enum Color: String {
    case red = "red"
    case green = "green"
    case blue = "blue"
    // A computed property. Note that enums cannot contain stored properties.
    var hint: String {
        switch self {
            case .red:
                return "Roses are this color."
            case .green:
                return "Grass is this color."
            case .blue:
                return "The ocean is this color."
        }
    }
    
    // An initializer like for classes.
    init?(color: String) {
        switch color {
        case "red":
            self = .red
        case "green":
            self = .green
        case "blue":
            self = .blue
        default:
            return nil
        }
    }
}

// Can extend the enum as well!
extension Color {
    // A function.
    func hintFunc() -> String {
        return self.hint
    }
}

let c = Color.red
print("Give me a hint for c: \(c.hintFunc())")

let invalidColor = Color(color: "orange")
print("is invalidColor nil: \(invalidColor == nil)")
Give me a hint for c: Roses are this color.
is invalidColor nil: true

Now, let's look at structs. Notice that we cannot inherit structs, but instead can use protocols:

struct FastCar {
    // Can have variables and constants as stored properties.
    var color: Color
    let horsePower: Int
    // Can have computed properties.
    var watts: Float {
       return Float(horsePower) * 745.7
    }
    // Can have lazy variables like in classes!
    lazy var titleCaseColorString: String = {
        let colorString = color.rawValue
        return colorString.prefix(1).uppercased() + 
               colorString.lowercased().dropFirst()
    }()
    // A function.
    func description() -> String {
        return "This is a \(color) car with \(horsePower) horse power!"
    }
    // Can create a variety of initializers.
    init(color: Color, horsePower: Int) {
        self.color = color
        self.horsePower = horsePower
    }
    // Can define extra initializers other than the default one.
    init?(color: String, horsePower: Int) {
        guard let enumColor = Color(color: color) else {
            return nil
        }
        self.color = enumColor
        self.horsePower = horsePower
    }
}

var car = FastCar(color: .red, horsePower: 250)
print(car.description())
print("Horse power in watts: \(car.watts)")
print(car.titleCaseColorString)
This is a red car with 250 horse power!
Horse power in watts: 186425.0
Red

Finally, let's see how they are pass by value types unlike classes:

// Notice we have no problem modifying a constant class with 
// variable properties.
class A {
  var a = "a"
}

func foo(_ a: A) {
  a.a = "foo"
}
let a = A()
print(a.a)
foo(a)
print(a.a)

/* 
Uncomment the following code to see how an error is thrown.
Structs are implicitly passed by value, so we cannot modify it.
> "error: cannot assign to property: 'car' is a 'let' constant"
*/

// func modify(car: Car, toColor color: Color) -> Void {
//   car.color = color
// }

// car = Car(color: .red, horsePower: 250)
// print(car.description())
// modify(car: &car, toColor: .blue)
// print(car.description())
a
foo

Let's use protocols

Let's start by creating protocols for different cars:

protocol Car {
    var color: Color { get set }
    var price: Int { get }
    func turnOn()
    mutating func drive()
}

protocol Electric {
    mutating func recharge()
    // percentage of the battery level, 0-100%.
    var batteryLevel: Int { get set }
}

protocol Gas {
    mutating func refill()
    // # of liters the car is holding, varies b/w models.
    var gasLevelLiters: Int { get set }
}

In an object-oriented world (with no multiple inheritance), you may have made Electric and Gas abstract classes then used class inheritance to make both inherit from Car, and then have a specific car model be a base class. However, here both are completely separate protocols with zero coupling! This makes the entire system more flexible in how you design it.

Let's define a Tesla:

struct TeslaModelS: Car, Electric {
    var color: Color // Needs to be a var since `Car` has a getter and setter.
    let price: Int
    var batteryLevel: Int
    
    func turnOn() {
        print("Starting all systems!")
    }

    mutating func drive() {
        print("Self driving engaged!")
        batteryLevel -= 8
    }

    mutating func recharge() {
        print("Recharging the battery...")
        batteryLevel = 100
    }
}

var tesla = TeslaModelS(color: .red, price: 110000, batteryLevel: 100)

This specifies a new struct TeslaModelS that conforms to both protocols Car and Electric.

Now let’s define a gas powered car:

struct Mustang: Car, Gas{
    var color: Color
    let price: Int
    var gasLevelLiters: Int
    
    func turnOn() {
        print("Starting all systems!")
    }
    
    mutating func drive() {
        print("Time to drive!")
        gasLevelLiters -= 1
    }
    
    mutating func refill() {
        print("Filling the tank...")
        gasLevelLiters = 25
    }
}

var mustang = Mustang(color: .red, price: 30000, gasLevelLiters: 25)

Extend protocols with default behaviors

What you can notice from the examples is that we have some redundancy. Every time we recharge an electric car, we need to set the battery percentage level to 100. Since all electric cars have a max capacity of 100%, but gas cars vary between gas tank capacity, we can default the level to 100 for electric cars.

This is where extensions in Swift can come in handy:

extension Electric {
    mutating func recharge() {
        print("Recharging the battery...")
        batteryLevel = 100
    }
}

So now, any new electric car we create will set the battery to 100 when we recharge it. Thus, we have just been able to decorate classes, structs, and enums with unique and default behavior.

Protocol Comic

Thanks to Ray Wenderlich for the comic!

However, one thing to watch out for is the following. In our first implementation, we define foo() as a default implementation on A, but not make it required in the protocol. So when we call a.foo(), we get "A default" printed.

protocol Default {}

extension Default {
    func foo() { print("A default")}
}

struct DefaultStruct: Default {
    func foo() {
        print("Inst")
    }
}

let a: Default = DefaultStruct()
a.foo()
A default

However, if we make foo() required on A, we get "Inst":

protocol Default {
    func foo()
}

extension Default {
    func foo() { 
        print("A default")
    }
}

struct DefaultStruct: Default {
    func foo() {
        print("Inst")
    }
}

let a: Default = DefaultStruct()
a.foo()
Inst

This occurs due to a difference between static dispatch in the first example and static dispatch in the second on protocols in Swift. For more info, refer to this Medium post.

Overriding default behavior

However, if we want to, we can still override the default behavior. One important thing to note is that this doesn’t support dynamic dispatch.

Let’s say we have an older version of an electric car, so the battery health has been reduced to 90%:

struct OldElectric: Car, Electric {
    var color: Color // Needs to be a var since `Car` has a getter and setter.
    let price: Int
    var batteryLevel: Int
    
    func turnOn() {
        print("Starting all systems!")
    }
    
    mutating func drive() {
        print("Self driving engaged!")
        batteryLevel -= 8
    }
    
    mutating func reCharge() {
        print("Recharging the battery...")
        batteryLevel = 90
    }
}

Standard library uses of protocols

Now that we have an idea how protocols in Swift work, let's go through some typical examples of using the standard library protocols.

Extend the standard library

Let's see how we can add additional functionality to types that exist in Swift already. Since types in Swift aren't built in, but are part of the standard library as structs, this is easy to do.

Let's try and do binary search on an array of elements, while also making sure to check that the array is sorted:

extension Collection where Element: Comparable {
    // Verify that a `Collection` is sorted.
    func isSorted(_ order: (Element, Element) -> Bool) -> Bool {
        var i = index(startIndex, offsetBy: 1)
        
        while i < endIndex {
            // The longer way of calling a binary function like `<(_:_:)`, 
            // `<=(_:_:)`, `==(_:_:)`, etc.
            guard order(self[index(i, offsetBy: -1)], self[i]) else {
                return false
            }
            i = index(after: i)
        }
        return true
    }
    
    // Perform binary search on a `Collection`, verifying it is sorted.
    func binarySearch(_ element: Element) -> Index? {
        guard self.isSorted(<=) else {
            return nil
        }
        
        var low = startIndex
        var high = endIndex
        
        while low <= high {
            let mid = index(low, offsetBy: distance(from: low, to: high)/2)

            if self[mid] == element {
                return mid
            } else if self[mid] < element {
                low = index(after: mid)
            } else {
                high = index(mid, offsetBy: -1)
            }
        }
        
        return nil
    }
}

print([2, 2, 5, 7, 11, 13, 17].binarySearch(5)!)
print(["a", "b", "c", "d"].binarySearch("b")!)
print([1.1, 2.2, 3.3, 4.4, 5.5].binarySearch(3.3)!)
2
1
2

We do this by extending the Collection protocol which defines "a sequence whose elements can be traversed multiple times, nondestructively, and accessed by an indexed subscript." Since arrays can be indexed using the square bracket notation, this is the protocol we want to extend.

Similarly, we only want to add this utility function to arrays whose elements can be compared. This is the reason why we have where Element: Comparable.

The where clause is a part of Swift's type system, which we will cover soon, but in short lets us add additional requirements to the extension we are writing, such as to require the type to implement a protocol, to require two types to be the same, or to require a class to have a particular superclass.

Element is the associated type of the elements in a Collection-conforming type. Element is defined within the Sequence protocol, but since Collection inherits from Sequence, it inherits the Element associated type.

Comparable is a protocol that defines "a type that can be compared using the relational operators <, <=, >=, and >.". Since we are performing binary search on a sorted Collection, this of course has to be true or else we don't know whether to recurse/iterate left or right in the binary search.

As a side note about the implementation, for more info on the index(_:offsetBy:) function that was used, refer to the following documentation.

Generics + protocols = 💥

Generics and protocols can be a powerful tool if used correctly to avoid duplicate code.

Firstly, look over another tutorial, A Swift Tour, which briefly covers generics at the end of the Colab book.

Assuming you have a general idea about generics, let's quickly take a look at some advanced uses.

When a single type has multiple requirements such as a type conforming to several protocols, you have several options at your disposal:

typealias ComparableReal = Comparable & FloatingPoint

func foo1<T: ComparableReal>(a: T, b: T) -> Bool {
    return a > b
}

func foo2<T: Comparable & FloatingPoint>(a: T, b: T) -> Bool {
    return a > b
}

func foo3<T>(a: T, b: T) -> Bool where T: ComparableReal {
    return a > b
}

func foo4<T>(a: T, b: T) -> Bool where T: Comparable & FloatingPoint {
    return a > b
}

func foo5<T: FloatingPoint>(a: T, b: T) -> Bool where T: Comparable {
    return a > b
}

print(foo1(a: 1, b: 2))
print(foo2(a: 1, b: 2))
print(foo3(a: 1, b: 2))
print(foo4(a: 1, b: 2))
print(foo5(a: 1, b: 2))
false
false
false
false
false

Notice the use of typealias at the top. This adds a named alias of an existing type into your program. After a type alias is declared, the aliased name can be used instead of the existing type everywhere in your program. Type aliases do not create new types; they simply allow a name to refer to an existing type.

Now, let's see how we can use protocols and generics together.

Let's imagine we are a computer store with the following requirements on any laptop we sell for determining how we organize them in the back of the store:

enum Box {
    case small
    case medium
    case large
}

enum Mass {
    case light
    case medium
    case heavy
}

// Note: `CustomStringConvertible` protocol lets us pretty-print a `Laptop`.
struct Laptop: CustomStringConvertible {
    var name: String
    var box: Box
    var mass: Mass
    
    var description: String {
        return "(\(self.name) \(self.box) \(self.mass))"
    }
}

However, we have a new requirement of grouping our Laptops by mass since the shelves have weight restrictions.

func filtering(_ laptops: [Laptop], by mass: Mass) -> [Laptop] {
    return laptops.filter { $0.mass == mass }
}

let laptops: [Laptop] = [
    Laptop(name: "a", box: .small, mass: .light),
    Laptop(name: "b", box: .large, mass: .medium),
    Laptop(name: "c", box: .medium, mass: .heavy),
    Laptop(name: "d", box: .large, mass: .light)
]

let filteredLaptops = filtering(laptops, by: .light)
print(filteredLaptops)
[(a small light), (d large light)]

However, what if we wanted to filter by something other than Mass?

One option is to do the following:

// Define a protocol which will act as our comparator.
protocol DeviceFilterPredicate {
    associatedtype Device
    func shouldKeep(_ item: Device) -> Bool
}

// Define the structs we will use for passing into our filtering function.
struct BoxFilter: DeviceFilterPredicate {
    typealias Device = Laptop
    var box: Box 
    
    func shouldKeep(_ item: Laptop) -> Bool {
        return item.box == box
    }
}

struct MassFilter: DeviceFilterPredicate {
    typealias Device = Laptop  
    var mass: Mass
    
    func shouldKeep(_ item: Laptop) -> Bool {
        return item.mass == mass
    }
}

// Make sure our filter conforms to `DeviceFilterPredicate` and that we are 
// filtering `Laptop`s.
func filtering<F: DeviceFilterPredicate>(
    _ laptops: [Laptop], 
    by filter: F
) -> [Laptop] where Laptop == F.Device {
    return laptops.filter { filter.shouldKeep($0) }
}

// Let's test the function out!
print(filtering(laptops, by: BoxFilter(box: .large)))
print(filtering(laptops, by: MassFilter(mass: .heavy)))
[(b large medium), (d large light)]
[(c medium heavy)]

Awesome! Now we are able to filter based on any laptop constraint. However, we are only able to filter Laptops.

What about being able to filter anything that is in a box and has mass? Maybe this warehouse of laptops will also be used for servers which have a different customer base:

// Define 2 new protocols so we can filter anything in a box and which has mass.
protocol Weighable {
    var mass: Mass { get }
}

protocol Boxed {
    var box: Box { get }
}

// Define the new Laptop and Server struct which have mass and a box.
struct Laptop: CustomStringConvertible, Boxed, Weighable {
    var name: String
    var box: Box
    var mass: Mass
    
    var description: String {
        return "(\(self.name) \(self.box) \(self.mass))"
    }
}

struct Server: CustomStringConvertible, Boxed, Weighable {
    var isWorking: Bool
    var name: String
    let box: Box
    let mass: Mass

    var description: String {
        if isWorking {
            return "(working \(self.name) \(self.box) \(self.mass))"
        } else {
            return "(notWorking \(self.name) \(self.box) \(self.mass))"
        }
    }
}

// Define the structs we will use for passing into our filtering function.
struct BoxFilter<T: Boxed>: DeviceFilterPredicate {
    var box: Box 
  
    func shouldKeep(_ item: T) -> Bool {
        return item.box == box
    }
}

struct MassFilter<T: Weighable>: DeviceFilterPredicate {
    var mass: Mass
    
    func shouldKeep(_ item: T) -> Bool {
        return item.mass == mass
    }
}

// Define the new filter function.
func filtering<F: DeviceFilterPredicate, T>(
    _ elements: [T], 
    by filter: F
) -> [T] where T == F.Device {
    return elements.filter { filter.shouldKeep($0) }
}


// Let's test the function out!
let servers = [
    Server(isWorking: true, name: "serverA", box: .small, mass: .heavy),
    Server(isWorking: false, name: "serverB", box: .medium, mass: .medium),
    Server(isWorking: true, name: "serverC", box: .large, mass: .light),
    Server(isWorking: false, name: "serverD", box: .medium, mass: .light),
    Server(isWorking: true, name: "serverE", box: .small, mass: .heavy)
]

let products = [
    Laptop(name: "a", box: .small, mass: .light),
    Laptop(name: "b", box: .large, mass: .medium),
    Laptop(name: "c", box: .medium, mass: .heavy),
    Laptop(name: "d", box: .large, mass: .light)
]

print(filtering(servers, by: BoxFilter(box: .small)))
print(filtering(servers, by: MassFilter(mass: .medium)))

print(filtering(products, by: BoxFilter(box: .small)))
print(filtering(products, by: MassFilter(mass: .medium)))
[(working serverA small heavy), (working serverE small heavy)]
[(notWorking serverB medium medium)]
[(a small light)]
[(b large medium)]

We have now been able to filter an array by not only any property of a specific struct, but also be able to filter any struct which has that property!

Tips for good API design

This section was taken from the WWDC 2019: Modern Swift API Design talk.

Now that you understand how protocols behave, it's best to go over when you should use protocols. As powerful as protocols can be, it's not always the best idea to dive in and immediately start with protocols.

  • Start with concrete use cases:
    • First explore the use case with concrete types and understand what code it is you want to share and find is being repeated. Then, factor that shared code out with generics. It might mean to create new protocols. Discover a need for generic code.
  • Consider composing new protocols from existing protocols defined in the standard library. Refer to the following Apple documentation for a good example of this.
  • Instead of a generic protocol, consider defining a generic type instead.

Example: defining a custom vector type

Let's say we want to define a GeometricVector protocol on floating-point numbers to use in some geometry app we are making which defines 3 important vector operations:

protocol GeometricVector {
    associatedtype Scalar: FloatingPoint
    static func dot(_ a: Self, _ b: Self) -> Scalar
    var length: Scalar { get }
    func distance(to other: Self) -> Scalar
}

Let's say we want to store the dimensions of the vector, which the SIMD protocol can help us with, so we will make our new type refine the SIMD protocol. SIMD vectors can be thought of as fixed size vectors that are very fast when you use them to perform vector operations:

protocol GeometricVector: SIMD {
    associatedtype Scalar: FloatingPoint
    static func dot(_ a: Self, _ b: Self) -> Scalar
    var length: Scalar { get }
    func distance(to other: Self) -> Scalar
}

Now, let us define the default implementations of the operations above:

extension GeometricVector {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a * b).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

And then we need to add a conformance to each of the types we want to add these abilities:

extension SIMD2: GeometricVector where Scalar: FloatingPoint { }
extension SIMD3: GeometricVector where Scalar: FloatingPoint { }
extension SIMD4: GeometricVector where Scalar: FloatingPoint { }
extension SIMD8: GeometricVector where Scalar: FloatingPoint { }
extension SIMD16: GeometricVector where Scalar: FloatingPoint { }
extension SIMD32: GeometricVector where Scalar: FloatingPoint { }
extension SIMD64: GeometricVector where Scalar: FloatingPoint { }

This three-step process of defining the protocol, giving it a default implementation, and then adding a conformance to multiple types is fairly repetitive.

Was the protocol necessary?

The fact that none of the SIMD types have unique implementations is a warning sign. So in this case, the protocol isn't really giving us anything.

Defining it in an extension of SIMD

If we write the 3 operators in an extension of the SIMD protocol, this can solve the problem more succinctly:

extension SIMD where Scalar: FloatingPoint {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a * b).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

Using less lines of code, we added all the default implementations to all the types of SIMD.

Sometimes you may be tempted to create this hierarchy of types, but remember that it isn't always necessary. This also means the binary size of your compiled program will be smaller, and your code will be faster to compile.

However, this extension approach is great for when you have a few number of methods you want to add. However, it does hit a scalability issue when you are designing a larger API.

Is-a? Has-a?

Earlier we said GeometricVector would refine SIMD. But is this a is-a relationship? The problem is that SIMD defines operations which lets us add a scalar 1 to a vector, but it doesn't make sense to define such an operation in the context of geometry.

So, maybe a has-a relationship would be better by wrapping SIMD in a new generic type that can handle any floating point number:

// NOTE: `Storage` is the underlying type that is storing the values, 
// just like in a `SIMD` vector.
struct GeometricVector<Storage: SIMD> where Storage.Scalar: FloatingPoint {
    typealias Scalar = Storage.Scalar
    var value: Storage
    init(_ value: Storage) { self.value = value }
}

We can then be careful and only define the operations that make sense only in the context of geometry:

extension GeometricVector {
    static func + (a: Self, b: Self) -> Self {
        Self(a.value + b.value)
    }

    static func - (a: Self, b: Self) -> Self {
        Self(a.value - b.value)
    }
    static func * (a: Self, b: Scalar) -> Self {
        Self(a.value * b)
    }
}

And we can still use generic extensions to get the 3 previous operators we wanted to implement which look almost the exact same as before:

extension GeometricVector {
    static func dot(_ a: Self, _ b: Self) -> Scalar {
        (a.value * b.value).sum()
    }

    var length: Scalar {
        Self.dot(self, self).squareRoot()
    }

    func distance(to other: Self) -> Scalar {
        (self - other).length
    }
}

Overall, we have been able to refine the behavior of our three operations to a type by simply using a struct. With protocols, we faced the issue of writing repetitive conformances to all the SIMD vectors, and also weren't able to prevent certain operators like Scalar + Vector from being available (which in this case we didn't want). As such, remember that protocols are not a be-all and end-all solution. But sometimes more traditional solutions can prove to be more powerful.

More protocol-oriented programming resources

Here are additional resources on the topics discussed:

  • WWDC 2015: Protocol-Oriented Programming in Swift: this was presented using Swift 2, so a lot has changed since then (e.g. name of the protocols they used in the presentation) but this is still a good resource for the theory and uses behind it.
  • Introducing Protocol-Oriented Programming in Swift 3: this was written in Swift 3, so some of the code may need to be modified in order to have it compile successfully, but it is another great resource.
  • WWDC 2019: Modern Swift API Design: goes over the differences between value and reference types, a use case of when protocols can prove to be the worse choice in API design (same as the "Tips for Good API Design" section above), key path member lookup, and property wrappers.
  • Generics: Swift's own documentation for Swift 5 all about generics.