Protocol-Oriented Programming in Swift

Description: At the heart of Swift's design are two incredibly powerful ideas: protocol-oriented programming and first class value semantics. Each of these concepts benefit predictability, performance, and productivity, but together they can change the way we think about programming. Find out how you can apply these ideas to improve the code you write.

Classes are Awesome?

Classes give us:

  • Encapsulation
  • Access control
  • Abstraction
  • Namespaces
  • Expressive syntax
  • Extensibility

Access control, abstraction, and namespacing help us manage complexity.

We can do all of the above with structs and enums too. So, types are awesome.

Inheritance Hierarchies

Customization points and reuse

A superclass can define a function, and subclasses get that functionality for free. The magic happens when the author breaks out a small part of that operation into a separate customization point.

The subclass can then override this customization point. Subclasses can reuse difficult logic while maintaining open-ended flexibility.

3 Problems with Classes

1. Implicit sharing

Let's look at a common scenario. A and B share some data, but A cannot modify this data without affecting B:

Programmers can defensively make copies of data, but that leads to inefficiency.

Also, modifying data from different threads can lead to race conditions, and defensively adding locks leads to further inefficiency + deadlocks.

All of this leads to further complexity--in a word, bugs.

One effect of implicit sharing on Cocoa platforms:

It is not safe to modify a mutable collection while enumerating through it. Some enumerators may currently allow enumeration of a collection that is modified, but this behavior is not guaranteed to be supported in the future.

This does not apply to Swift because all Swift collections are value types. The collection we are iterating over and the one we are modifying are distinct.

2. Class inheritance is too intrusive

Class inheritance is monolithic--we get only 1 superclass. What if we want to model multiple abstractions? For example, what if our class wants to be a collection and be serialized? We can't if both Collection and Serialized are classes.

Classes also get bloated as everything that might be related gets thrown together.

We also have to choose our superclass at the moment that we define our class, not later in some extension.

What if our superclass had stored properties? We have to accept them, and we have to initialize them even if we don't need them. We also need to do this without breaking any of the superclass's invariants.

Finally, it's natural for authors to write their code as if they know what to override (and more importantly, what not to override). For example, they might not use final and they might not account for a method being overwritten.

This is why Cocoa programmers increasingly use the delegate pattern.

3. Lost type relationships

If we want to write a generalized sort or binary search, we need a way to compare 2 elements. With classes, we end up with something like this:

class Ordered {
  func precedes(other: Ordered) -> Bool
}

func binarySearch(sortedKeys: [Ordered], forKey k: Ordered) -> Int {
  var low = 0, high = sortedKeys.count

  while high > low {
    let mid = low + (high - low) / 2

    if sortedKeys[mid].precedes(k) {
      low = mid + 1
    } else {
      high = mid
    }
  }

  return low
}

Of course, we can't just define a class function without filling in it's body. What can we put there if we don't know anything about an arbitrary instance of Ordered? There's really nothing we can do other than error out:

class Ordered {
  func precedes(other: Ordered) -> Bool {
    fatalError("implement me!")
  }
}

This is the first sign that we're fighting the type system. We can't just brush this aside saying that "as long as each subclass of Ordered implements precedes, we'll be ok".

Let's say we move along and implement a subclass of Ordered:

class Number: Ordered {
  var value: Double = 0

  override func precedes(other: Ordered) -> Bool {
    return value < other.value  // ERROR: 'Ordered' does not have a member named 'value'.
  }
}

This won't compile because Ordered could be anything, such as a Label class. Now we need to downcast to get to the right type:

return value < (other as! Number).value

What if other turns out to be a Label? Our code would trap. We run into this error because classes don't let us express the crucial type relationship between the type of self and the type of other.

In fact, any time we see as! Subclass in our code, we should treat it as a code smell. This is a sign that a type relationship was lost, usually due to using classes for abstraction.

We need a better abstraction mechanism

We need a mechanism that that:

  • Supports value types and classes
  • Supports static type relationships and dynamic dispatch
  • Is non-monolithic
  • Supports retroactive modeling (can conform to another type's requirements in an extension)
  • Doesn't impose instance data on models
  • Doesn't impose initialization burdens on models
  • Makes clear what to implement/override

The answer: Protocols

Swift is the first protocol-oriented programming language. From the way for loops and string literals work to the emphasis in the standard library on generics, at its heart, Swift is protocol-oriented.

Start with a protocol, not a class

Let's refactor our last example. Protocols don't allow method bodies, and there's nothing to override. Lastly, a Number doesn't need to be a reference type, so we can change it to a struct:

protocol Ordered {
  func precedes(other: Ordered) -> Bool
}

struct Number: Ordered {
  var value: Double = 0

  func precedes(other: Ordered) -> Bool {
    return self.value < (other as! Number).value
  }
}

We still have the static type safety hole when we downcast to Number. We can't drop the typecase by changing Ordered to Number because then Number wouldn't conform to Ordered:

func precedes(other: Number) -> Bool {
  // ERROR: protocol requires function 'precedes' with type '(Ordered) -> Bool'
  // candidate has non-matching type '(Number) -> Bool'
  return self.value < other.value
}

To make this code compile, we need to add a Self requirement to the protocol. Self is a placeholder for the dynamic type that conforms to the protocol:

protocol Ordered {
  func precedes(other: Self) -> Bool
}

Now we can refactor our binary search. When Ordered was a class, our function declaration looked like:

func binarySearch(sortedKeys: [Ordered], forKey k: Ordered) -> Int { ... }

Notice that, when Ordered was a class, our array of Ordered could contain any subclass. For example, we could have an array of [Number, Label, Fruit] if all 3 were subclasses of Ordered.

After changing Ordered to a protocol and adding the Self requirement, our array is expected to only contain one conforming type. We get the following error:

protocol 'Ordered' can only be used as a generic constraint because it has Self or associated type requirements.

To fix the compiler error, we have to declare that our function will only work on any homogeneous array of type T which conforms to Ordered:

func binarySearch<T: Ordered>(sortedKeys: [T], forKey k: T) -> Int { ... }

Did we lose flexibility by forcing our array to be homogeneous? Not at all! Earlier, we handled the homogeneous case by trapping:

class Ordered {
  func precedes(other: Ordered) -> Bool {
    fatalError("implement me!")
  }
}

A homogeneous array is what we wanted in the first place.

Two worlds of protocols

Without Self RequirementWith Self Requirement
func precedes(other: Ordered) -> Boolfunc precedes(other: Self) -> Bool
Usable as a type
func sort(inout a: [Ordered])
Only usable as a generic constraint
func sort<T: Ordered>(inout a: [T])
Think "heterogeneous"Think "homogeneous"
Every model must deal with all othersModels are free from interaction
Dynamic dispatchStatic dispatch
Less optimizableMore optimizable

Replacing classes in OOP with protocols

We're going to model a diagramming app where users can drag and drop shapes on a drawing surface and then interact with those shapes. We're building the document and display model.

First, a primitive "renderer" which just prints out drawing commands:

struct Renderer {
  func move(to point: CGPoint) {
    print("Move to (\(point.x), \(point.y))")
  }

  func line(to point: CGPoint) {
    print("Line to (\(point.x), \(point.y))")
  }

  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
    print("Arc at \(center), radius: \(radius), startAngle: \(startAngle), endAngle: \(endAngle)")
  }
}

Next, a Drawable protocol which provides a common interface for all our drawing elements:

protocol Drawable {
  func draw(using renderer: Renderer)
}

Then, shapes like polygons. Notice that this is a value type built out of another value type, CGPoint:

struct Polygon: Drawable {
  var corners = [CGPoint]()

  func draw(using renderer: Renderer) {
    renderer.move(to: corners.last!)

    for point in corners {
      renderer.line(to: point)
    }
  }
}

Here's a circle, which is also a value type built out of other value types:

struct Circle: Drawable {
  var center: CGPoint
  var radius: CGFloat

  func draw(using renderer: Renderer) {
    renderer.arc(at: center, radius: radius, startAngle: 0.0, endAngle: twoPi)
  }
}

Now, we can build a diagram out of circles and polygons:

struct Diagram: Drawable {
  var elements = [Drawable]()

  func draw(using renderer: Renderer) {
    for element in elements {
      element.draw(using: renderer)
    }
  }
}

Let's test it

Here's the test with some curiously specific values:

var circle = Circle(center: CGPoint(x: 187.5, y: 333.5), radius: 93.75)

var triangle = Polygon(corners: [
  CGPoint(x: 187.5, y: 427.25),
  CGPoint(x: 268.69, y: 286.625),
  CGPoint(x: 106.31, y: 286.625)
])

var diagram = Diagram(elements: [circle, triangle])
diagram.draw(using: Renderer())

The output, clearly an equilateral triangle with a circle inscribed in a circle (meant to be non-obvious/a joke):

$ ./test
Arc at (187.5, 333.5), radius: 93.75, startAngle: 0.0, endAngle: 6.28318530717959
Move to (106.310118395209, 286.625)
Line to (187.5, 427.25)
Line to (268.689881604791, 286.625)
Line to (106.310118395209, 286.625)

There's a good use for a text renderer: testing! We can easily see check if values change. Let's change Renderer to a protocol:

protocol Renderer {
  func move(to point: CGPoint)
  func line(to point: CGPoint)
  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)
}

struct TestRenderer: Renderer {
  func move(to point: CGPoint) {
    print("Move to (\(point.x), \(point.y))")
  }

  func line(to point: CGPoint) {
    print("Line to (\(point.x), \(point.y))")
  }

  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
    print("Arc at \(center), radius: \(radius), startAngle: \(startAngle), endAngle: \(endAngle)")
  }
}

Making the real renderer

Now that we have our protocol defined, it's easy to turn Core Graphics into a renderer--we don't even need a new type. Recall that this makes use of retroactive modeling:

extension CGContext: Renderer {
  func move(to point: CGPoint) {
    CGContextMoveToPoint(self, position.x, position.y)
  }

  func line(to point: CGPoint) {
    CGContextAddLineToPoint(self, position.x, position.y)
  }

  func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
    let arc = CGPathCreateMutable()
    CGPathAddArc(arc, nil, center.x, center.y, radius, startAngle, endAngle, true)
    CGContextAddPath(self, arc)
  }
}

Output:

Nesting diagrams

In our test code, what happens if we add the following line?

// ... set up circle, triangle, diagram...
diagram.elements.append(diagram)

We might expect our code to go into infinite recursion, but it doesn't. To learn why, see Building Better Apps with Value Types in Swift.

This code also doesn't change the display at all because it just repeats the same drawing commands twice; essentially, there's 2 diagrams, but they're directly on top of each other.

Reflecting on TestRenderer

By decoupling the document model from a specific renderer, we were able to plug in a test renderer that reveals everything our code does in detail. Decoupling with protocols + generics makes our code much more testable.

This kind of testing is similar to what we might get with mocks, but it's even better. Mocks are inherently fragile; testing code needs to be coupled to the implementation details of the code under test. This fragility means mocks don't play well with Swift's strong, static type system. On the other hand, protocols use the strong type system but still give us hooks to plug in all the instrumentation we need.

Back to our example

In our app, a bubble is just an inner circle offset around the center of the outer circle.

The code looks like this. Notice how the startAngle and endAngle parameters are the same as in Circle:

struct Bubble: Drawable {
  // ...
  func draw(using renderer: Renderer) {
    renderer.arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
    renderer.arc(at: highlightCenter, radius: highlightRadius, startAngle: 0, endAngle: twoPi)
  }
}

struct Circle: Drawable {
  // ...
  func draw(using renderer: Renderer) {
    renderer.arc(at: center, radius: radius, startAngle: 0.0, endAngle: twoPi)
  }
}

To resolve this repetition, we could start adding a new requirement to the Renderer protocol and then update our models to maintain conformance:

protocol Renderer {
  // move(to:), line(to:), arc(at:radius:startAngle:endAngle)
  func circle(at center: CGPoint, radius: CGFloat)
}

extension TestRenderer {
  func circle(at center: CGPoint, radius: CGFloat) {
    arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
  }
}

extension CGContext {
  func circle(at center: CGPoint, radius: CGFloat) {
    arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
  }
}

However, TestRenderer and CGContext also have duplicate implementations. We still have repeated code!

Protocol Extensions

Instead of duplicating code, we can use a protocol extension to provide a default implementation of circle(at:radius:) for both TestRenderer and CGContext, as well as any future renderers:

extension Renderer {
  func circle(at center: CGPoint, radius: CGFloat) {
    arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
  }
}

Protocols define requirements which can be immediately fulfilled in extensions. This creates a customization point. To see how this plays out, let's see what happens when we add another method rectangle(at:) to the extension that isn't part of the requirements:

extension Renderer {
  func circle(at center: CGPoint, radius: CGFloat) { ... }
  func rectangle(at edges: CGRect) { ... }
}

Now we can extend our TestRenderer to implement both of these methods and then call them:

extension TestRenderer: Renderer {
  func circle(at center: CGPoint, radius: CGFloat) { ... }
  func rectangle(at edges: CGRect) { ... }
}

let r = TestRenderer()
r.circle(at: origin, radius: 1)
r.rectangle(at: edges)

The result isn't surprising; circle(at:radius:) and rectangle(at:) use the implementations inside TestRenderer instead of the default implementations from the Renderer protocol extension. We would get the same result if we removed any conformance.

Let's change the context; let's assume Swift only knows it has a Renderer, not a TestRenderer:

let r: Renderer = TestRenderer()
r.circle(at: origin, radius: 1)  // Uses TestRenderer implementation.
r.rectangle(at: edges)  // Uses Renderer implementation.

Because circle(at:radius:) is a requirement inside Renderer, our TestRenderer gets the privilege of customizing it, so the TestRenderer implementation is called. However, rectangle(at:) is not a requirement; the implementation in TestRenderer only shadows the protocol implementation. In this context, where Swift only knows it has some kind of Renderer, the protocol implementation is called.

Should rectangle(at:) have been a requirement? Possibly; some renderers are highly likely to have a more efficient implementation for their specific use case.

Should everything in your protocol extension also be backed by a requirement? Not necessarily; some APIs are just not intended as customization points. Sometimes, the right choice is to use the default implementation.

More protocol extension tricks

Constrained Extensions

We can add extensions that are only valid under some conditions. For example, this indexOf function would not compile because Generator.Element is not necessarily Equatable:

extension CollectionType {
  public func indexOf(element: Generator.Element) -> Index? {
    for i in self.indices {
      // ERROR: binary operator '==' cannot be applied to two Generator.Element operands
      if self[i] == element {
        return i
      }
    }
    return nil
  }
}

All we need to do as add a constraint to the extension so indexOf is only valid when the types can be compared for equality:

extension CollectionType where Generator.Element: Equatable

Retroactive Adaptation

We can do something similar for our binary search. Our previous implementation was:

protocol Ordered {
  func precedes(other: Self) -> Bool
}

func binarySearch<T: Ordered>(sortedKeys: [T], forKey k: T) -> Int { ... }

Unfortunately, this does not work with a simple array of integers:

// ERROR: cannot invoke 'binarySearch' with an argument list of type '([Int],forKey:Int)'
let position = binarySearch([2, 3, 5, 7], forKey: 5)

We could fix this by adding a conformance to Ordered for Int, String, and so on, but that isn't maintainable. Instead, we can provide a default implementation for any type that implements Comparable:

extension Ordered where Self: Comparable {
  func precedes(other: Self) -> Bool {
    return self < other
  }
}

Now, because Int, String, Double, etc. all conform to Comparable, we can perform binary search on an array of any Comparable type.

Generic beautification

This is the signature of a fully generalized binary search that works on any element with the appropriate index and element types:

// Swift 1
func binarySearch<
  C: CollectionType where C.Index == RandomAccessIndexType, C.Generator.Element: Ordered
>(sortedKeys: C, forKey k: C.Generator.Element) -> Int { ... }

let pos = binarySearch([2, 3, 5, 7, 11, 13, 17], forKey: 5)

Swift 1 had lots of generic free functions like this. Swift 2 used protocol extensions to make them into methods. Notice the improvement at the call site and at the declaration--no more angle brackets!

// Swift 2
extension CollectionType where Index == RandomAccessIndexType, Generator.Element: Ordered {
  func binarySearch(forKey: Generator.Element) -> Int { ... }
}

let pos = [2, 3, 5, 7, 11, 13, 17].binarySearch(forKey: 5)

Interface generation

Here's a minimal model of Swift's OptionSetType protocol. Notice the broad, set-like interface we get for free just by conforming to the protocol.

Returning to the diagram example

Make all value types Equatable

Always make value types Equatable. To know why, see Building Better Apps with Value Types in Swift.

Equatable is easy for most types--we just compare corresponding parts for equality. Unfortunately, this doesn't work with Diagram:

struct Diagram: Equatable {
  var elements = [Drawable]()
  func draw(using renderer: Renderer) { ... }
}

func ==(lhs: Diagram, rhs: Diagram) -> Bool {
  // ERROR: binary operator '==' cannot be applied to two [Drawable] operands.
  return lhs.elements == rhs.elements
}

We try comparing individual elements, but we run into the same error inside the contains closure because we never defined equality for Drawable.

func ==(lhs: Diagram, rhs: Diagram) -> Bool {
  return lhs.elements.count == rhs.elements.count
    && !zip(lhs.elements, rhs.elements).contains {
      $0 != $1  // ERROR: binary operator '==' cannot be applied to two Drawable operands.
    }
}

Should every Drawable be Equatable?

The problem is that Equatable has Self requirements, which means that Drawable would have Self requirements.

protocol Equatable {
  func ==(Self, Self) -> Bool
}

This would put Drawable in the homogeneous, statically dispatched world, but a diagram requires a heterogeneous array of Drawables. Otherwise, we could not put polygons and circles in the same diagram.

Therefore, we should not make Drawable conform to Equatable.

Bridge-building

We essentially need to mandate that every Drawable defines an equitability function:

protocol Drawable {
  func isEqualTo(_ other: Drawable) -> Bool
  // ...
}

func ==(lhs: Diagram, rhs: Diagram) -> Bool {
  return lhs.elements.count == rhs.elements.count
    && !zip(lhs.elements, rhs.elements).contains { !$0.isEqualTo($1) }
}

We run into a problem because comparison requires some knowledge of Self, i.e. the dynamic type of the Drawable, because we need to handle cases where we're comparing two Drawable objects with different dynamic types. Does this mean it's impossible to keep Drawables heterogeneous?

Fortunately, in this case, no. The benefit of equality is that there's an obvious default answer of false if two types are not the same. So, we can implement isEqualTo for all Drawables when the dynamic type is Equatable:

extension Drawable where Self: Equatable {
  func isEqualTo(_ other: Drawable) -> Bool {
    guard let o = other as? Self else { return false }
    return self == o
  }
}

From a big picture perspective, we made a deal with the implementers of Drawable. We said, "If you really want to handle the heterogeneous case, go ahead. Otherwise, if you just want to use the regular way we express homogeneous comparison, there's a default implementation you can use."

When to use classes

Protocol-based design applies almost universally. However, classes do still have their place.

We want implicit sharing when:

  • It doesn't make sense to copy or compare instances
    • Ex: We don't really want to copy a graphical window. Otherwise, we would have 2 windows where 1 is not part of our view hierarchy. In this case, it makes sense to use a reference type.
  • Instance lifetime is tied to external effects
    • Compilers make/destroy entities unpredictably to perform optimizations. References, on the other hand, are stable. If we depend on an external entity, we should use a reference type.
  • Instances are just "sinks"
    • Here, a "sink" means something that just receives state.
    • Ex: If we wanted to make a test renderer that accumulated all commands into a string instead of writing to the console, we would want a reference type.
    • In the example below, notice that the class is final and that there's no inheritance; Renderer is still a protocol.
final class StringRenderer: Renderer {
  var result: String
  // ...
}

Other times to use classes:

  • Don't fight the system
    • If a framework expects you to subclass or to pass an object, do so.
  • On the other hand, be circumspect
    • Nothing in software should grow too large.
    • When factoring something out of a class, consider a value type.

Summary

Missing anything? Corrections? Contributions are welcome 😃

Related