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