This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We refactor a class hierarchy using a protocol and discuss the differences between both approaches.

00:09 Today we'll talk about protocols and class hierarchies. There was a session at WWDC about protocol-oriented programming, and in that session, they showed how to replace a class hierarchy with protocols. The reason for doing this is that class hierarchies are sometimes very inflexible. One problem most iOS developers have seen is the GodViewController. All other view controllers are supposed to inherit from GodViewController. That's very limiting, because it no longer allows you to inherit from UITableViewController (or any other view controller), as Swift has single inheritance.

00:55 With protocols, you can define shared functionality in a more flexible way than in a common superclass. The protocol-based approach isn't without limitations either though, and we'll look at them a bit later.

A Class Hierarchy

01:23 In the example code, we start out with a class hierarchy. We have a Shape class, which is an abstract superclass of sorts. Since Swift doesn't have abstract classes, we just provide implementations that call fatalError in the draw method and in the boundingBox property. Then we have a shared image method, which can be used by all of Shape's subclasses. That's the power of inheritance: you can reuse things you've written in your superclass:

class Shape {
    func draw(context: CGContext) {
        fatalError()
    }
    
    var boundingBox: CGRect {
        fatalError()
    }

    func image() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: boundingBox)
        return renderer.image { draw(context: $0.cgContext) }
    }
}

02:03 We have two subclasses of Shape. Rectangle inherits from Shape and has additional properties such as origin and size. It also overrides the boundingBox property and the draw method with specific implementations:

class Rectangle: Shape {
    var origin: CGPoint
    var size: CGSize
    var color: UIColor = .red
    
    init(origin: CGPoint, size: CGSize) {
        self.origin = origin
        self.size = size
    }
    
    override var boundingBox: CGRect {
        return CGRect(origin: origin, size: size)
    }
    
    override func draw(context: CGContext) {
        context.setFillColor(color.cgColor)
        context.fill(boundingBox)
    }
}

The Circle class is similar, but it draws circles instead:

class Circle: Shape {
    var center: CGPoint
    var radius: CGFloat
    var color: UIColor = .green
    
    init(center: CGPoint, radius: CGFloat) {
        self.center = center
        self.radius = radius
    }
    
    override var boundingBox: CGRect {
        return CGRect(origin: CGPoint(x: center.x-radius, y: center.y-radius), size: CGSize(width: radius*2, height: radius*2))
    }
    
    override func draw(context: CGContext) {
        context.setFillColor(color.cgColor)
        context.fillEllipse(in: boundingBox)
    }
}

Refactoring to a Protocol-Oriented Approach

02:38 To refactor this into a protocol-oriented approach, we'll change class Shape to protocol Shape and remove the default implementations for draw and boundingBox. We'll mark the boundingBox property as read-only. The shared image method is moved to a protocol extension:

protocol Shape {
    func draw(context: CGContext)
    var boundingBox: CGRect { get }
}

extension Shape {
    func image() -> UIImage {
        let renderer = UIGraphicsImageRenderer(bounds: boundingBox)
        return renderer.image { draw(context: $0.cgContext) }
    }
}

03:30 In the Rectangle definition, we can leave the first line as it is, because the syntax for subclassing and protocol conformance is the same. We just have to remove the override keywords:

class Rectangle: Shape {
    var origin: CGPoint
    var size: CGSize
    var color: UIColor = .red
    
    init(origin: CGPoint, size: CGSize) {
        self.origin = origin
        self.size = size
    }
    
    var boundingBox: CGRect {
        return CGRect(origin: origin, size: size)
    }
    
    func draw(context: CGContext) {
        context.setFillColor(color.cgColor)
        context.fill(boundingBox)
    }
}

03:50 For Circle, we have to make the same changes. This is all we need to do to create a protocol-oriented version of our code.

04:04 We can take this a bit further and replace all our classes with structs. We simply change the class keywords to struct and we're done. Even though it's a tiny change at the source level, it has big implications for the code. All of our rectangles and circles are values now, not references. It's not better or worse than having them as reference types, just very different. Defining Shape as a protocol gives us the option to use value types, whereas the inheritance approach requires us to use classes.

Adding Shared Functionality

05:10 Let's extend Shape by adding a method to rotate shapes. As a first attempt, we could add a mutating method to the protocol:

protocol Shape {
    func draw(context: CGContext)
    var boundingBox: CGRect { get }
    mutating func rotate(by angle: CGFloat)
}

05:33 However, now every type needs to implement the rotate method. With two types, we'll have duplication, but it'll only get worse when we add more types. It'd be nicer to provide a way to implement this method once for all Shapes.

06:10 Instead of defining a mutating rotate in the protocol, we define an immutable variant in a protocol extension:

extension Shape {
    func rotated(by angle: CGFloat) -> Shape {
        // ...
    }
}

06:16 For the angle, we currently use a CGFloat. It would be much more precise to use, for example, the Measurement API, because now it's unclear what the unit of the angle is: radians, degrees, or something else. However, we won't fix that today.

06:39 The way we'll implement rotated is by returning a new TransformedShape value. So we'll start by creating TransformedShape, which stores the original shape and a CGAffineTransform value:

struct TransformedShape {
    var original: Shape
    var transform: CGAffineTransform
}

07:03 We make TransformedShape conform to Shape in an extension. For the bounding box, we take the original bounding box and apply the transform:

extension TransformedShape: Shape {
    var boundingBox: CGRect {
        return original.boundingBox.applying(transform)
    }

    // ...
}

07:29 The draw method is a bit more complicated. The approach we'll take is to rotate the context and then call the original draw method. However, because the context is a mutable value, we have to make sure to restore it to the original state after we're done so that we don't influence other draw methods:

extension TransformedShape: Shape {
    // ...
    
    func draw(context: CGContext) {
        context.saveGState()
        context.concatenate(transform)
        original.draw(context: context)
        context.restoreGState()
    }
}

08:42 In the rotated method, we use TransformedShape's memberwise initializer to create the rotated shape:

extension Shape {
    func rotated(by angle: CGFloat) -> Shape {
        return TransformedShape(original: self, transform: CGAffineTransform(rotationAngle: angle))
    }
}

09:15 To try it out, we modify the sample code to draw a rotated rectangle:

let size = CGSize(width: 100, height: 200)
let rectangle = Rectangle(origin: .zero, size: CGSize(width: 100, height: 200))
rectangle.rotated(by: CGFloat(M_PI/6)).image()

Dispatch in Protocol Extensions

09:43 There are some tricky things to be aware of when working with protocols and protocol extensions. We're going to use a bit of a constructed example to demonstrate these pitfalls, but you'll encounter them sooner or later.

Let's say we want to override the rotated method for Circle and simply return the Circle directly:

struct Circle: Shape {
    // ...
    func rotated(by angle: CGFloat) -> Shape {
        return self
    }
}

10:26 Now, if we call circle.rotated(by:), we'll see that the overridden rotated method in the Circle struct gets called. However, with protocol-oriented APIs, you'll often store conforming entities as the protocol type. In our example, this means storing a circle as Shape, not as Circle. If you do this, the custom rotated method will no longer be called.

11:13 The reason for this is that methods defined in protocol extensions are statically dispatched. To have dynamic dispatch, we have to add our rotated method to the protocol itself:

protocol Shape {
    func draw(context: CGContext)
    var boundingBox: CGRect { get }
    func rotated(by angle: CGFloat) -> Shape
}

12:04 Now our overridden rotated method gets called again. This also explains why the protocols in the standard library have gotten so large. Even though there are default implementations for most methods in, for example, the Collection protocol, they're added to the protocol to allow for dynamic dispatch. The behavior around static and dynamic dispatch can be unintuitive at first, so it's important to be aware of the two different options.

Discussion

13:02 The protocol-oriented solution we came up with isn't necessarily better or worse. Both protocols and class hierarchies are tools we can use, and they come with different tradeoffs. For example, class hierarchies allow you to inherit stored properties, and you can call super when overriding something. In a protocol-oriented approach, this isn't possible.

A limitation of class hierarchies is that you can only use single inheritance. If you need shared functionality, you might run into the issue that you want to inherit from multiple classes, and that problem doesn't happen with protocols. With protocols, you can also add conformance later on, whereas with a class hierarchy, you can't replace a superclass unless you own that class. Depending on the problem you're solving, you can explore both solutions.

14:33 A related issue is that you often have to decide whether you need a protocol at all. For example, in the networking episode we have a Resource struct and not a Resource protocol. This is because each resource has the same structure: a URL, an HTTP method, and a parse function. In this case, it doesn't make sense to define a protocol.

For the example we worked on today, the different types have different properties. For example, a Rectangle has an origin and size, whereas a Circle has a center and radius. Because of those differences in structure, protocols are a much better fit than inheritance.

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    New subscriber-only episodes every two weeks

  • Invite Your Team

    Sign up additional team members at 30% discount

  • Support Us

    Ensure the continuous production of new episodes