Value-Oriented Programming
At WWDC 2015, in a very influential session titled Protocol-Oriented Programming in Swift, Dave Abrahams explained how Swift’s protocols can be used to overcome some shortcomings of classes. He suggested this rule: “Don’t start with a class. Start with a protocol”.
To illustrate the point, Dave described a protocol-oriented approach to a primitive drawing app. The example worked from a few of primitive shapes:
protocol Drawable {}
struct Polygon: Drawable {
var corners: [CGPoint] = []
}
struct Circle: Drawable {
var center: CGPoint
var radius: CGFloat
}
struct Diagram: Drawable {
var elements: [Drawable] = []
}
These are value types. That eliminates many of the problems of an object-oriented approach:
Instances aren’t shared implicitly
The reference semantics of objects add complexity when passing objects around. Changing a property of an object in one place can affect other code that has access to that object. Concurrency requires locking, which adds tons of complexity.
No problems from inheritance
Reusing code via inheritance is fragile. Inheritance also couples interfaces to implementations, which makes reuse more difficult. This is its own topic, but even OO programmers will tell you to prefer “composition over inheritance”.
No imprecise type relationships
With subclasses, it’s difficult to precisely identify types. e.g. with
NSObject.isEqual()
, you must be careful to only compare against compatible types. Protocols work with generics to precisely identify types.
To handle the actual drawing, a Renderer
protocol was added that describes the primitive drawing operations:
protocol Renderer {
func move(to p: CGPoint)
func line(to p: CGPoint)
func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)
}
Each type could then draw
with a Renderer
.
protocol Drawable {
func draw(_ renderer: Renderer)
}
extension Polygon : Drawable {
func draw(_ renderer: Renderer) {
renderer.move(to: corners.last!)
for p in corners {
renderer.line(to: p)
}
}
}
extension Circle : Drawable {
func draw(renderer: Renderer) {
renderer.arc(at: center, radius: radius, startAngle: 0.0, endAngle: twoPi)
}
}
extension Diagram : Drawable {
func draw(renderer: Renderer) {
for f in elements {
f.draw(renderer)
}
}
}
This made it possible to define different renderers that worked easily with the given types. A main selling point was the ability to define a test renderer, which let you verify drawing by comparing strings:
struct TestRenderer : Renderer {
func move(to p: CGPoint) { print("moveTo(\(p.x), \(p.y))") }
func line(to p: CGPoint) { print("lineTo(\(p.x), \(p.y))") }
func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
print("arcAt(\(center), radius: \(radius),"
+ " startAngle: \(startAngle), endAngle: \(endAngle))")
}
}
But you could also easily extend platform-specific types to make them work as renderers:
extension CGContext : Renderer {
// CGContext already has `move(to: CGPoint)`
func line(to p: CGPoint) {
addLine(to: p)
}
func arc(at center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) {
addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true
)
}
}
Lastly, Dave showed that you can extended the protocol to provide conveniences:
extension Renderer {
func circle(at center: CGPoint, radius: CGFloat) {
arc(at: center, radius: radius, startAngle: 0, endAngle: twoPi)
}
}
I think that approach is pretty compelling. It’s much more testable. It also allows us to interpret the data differently by providing separate renderers. And value types neatly sidestep a number of problems that an object-oriented version would have.
But I think there’s a better way to write this code.
Despite the improvements, logic and side effects are still tightly coupled in the protocol-oriented version. Polygon.draw
does 2 things: it converts the polygon into a number of lines and then renders those lines. So when it comes time to test the logic, we need to use TestRenderer
—which, despite what the WWDC talk implies, is a mock.
extension Polygon : Drawable {
func draw(_ renderer: Renderer) {
renderer.move(to: corners.last!)
for p in corners {
renderer.line(to: p)
}
}
}
We can separate logic and effects here by turning them into separate steps. Instead of the Renderer
protocol, with move
, line
, and arc
, let’s declare value types that represent the underlying operations.
enum Path: Hashable {
struct Arc: Hashable {
var center: CGPoint
var radius: CGFloat
var startAngle: CGFloat
var endAngle: CGFloat
}
struct Line: Hashable {
var start: CGPoint
var end: CGPoint
}
// Replacing `arc(at: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat)`
case arc(Arc)
// Replacing `move(to: CGPoint)` and `line(to: CGPoint)`
case line(Line)
}
Now, instead of calling those methods, Drawable
s can return a set of Path
s that are used to draw them:
protocol Drawable {
var paths: Set<Path> { get }
}
extension Polygon : Drawable {
var paths: Set<Path> {
return Set(zip(corners, corners.dropFirst() + corners.prefix(1))
.map(Path.Line.init)
.map(Path.line))
}
}
extension Circle : Drawable {
var paths: Set<Path> {
return [.arc(Path.Arc(center: center, radius: radius, startAngle: 0.0, endAngle: twoPi))]
}
}
extension Diagram : Drawable {
var paths: Set<Path> {
return elements
.map { $0.paths }
.reduce(into: Set()) { $0.formUnion($1) }
}
}
And now CGContext
to be extended to draw those paths:
extension CGContext {
func draw(_ arc: Path.Arc) {
addArc(
center: arc.center,
radius: arc.radius,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
clockwise: true
)
}
func draw(_ line: Path.Line) {
move(to: line.start)
addLine(to: line.end)
}
func draw(_ paths: Set<Path>) {
for path in paths {
switch path {
case let .arc(arc):
draw(arc)
case let .line(line):
draw(line)
}
}
}
}
And we can add our convenience method for creating circles:
extension Path {
static func circle(at center: CGPoint, radius: CGFloat) -> Path {
return .arc(Path.Arc(center: center, radius: radius, startAngle: 0, endAngle: twoPi))
}
}
This works just the same as before and requires roughly the same amount of code. But we’ve introduced a boundary that lets us separate two parts of the system. That boundary lets us:
Test without a mock
We don’t need
TestRenderer
anymore. We can verify that aDrawable
will be drawn correctly testing the values return from itspaths
property.Path
isEquatable
, so this is a simple test.let polygon = Polygon(corners: [(x: 0, y: 0), (x: 6, y: 0), (x: 3, y: 6)]) let paths: Set<Path> = [ .line(Line(from: (x: 0, y: 0), to: (x: 6, y: 0))), .line(Line(from: (x: 6, y: 0), to: (x: 3, y: 6))), .line(Line(from: (x: 3, y: 6), to: (x: 0, y: 0))), ] XCTAssertEqual(polygon.paths, paths)
Insert more steps
With the value-oriented approach, we can take our
Set<Path>
and transform it directly. Say you wanted to flip the result horizontally. You calculate the size and then return a newSet<Path>
with flipped coordinates.In the protocol-oriented approach, it would be somewhat difficult to transform our drawing steps. To flip horizontally, you need to know the final width. Since that width isn’t known ahead of time, you’d need to write a
Renderer
that (1) saved all the calls tomove
,line
, andarc
and then (2) pass it anotherRender
to render the flipped result.(This theoretical renderer is creating the same boundary we created with the value-oriented approach. Step 1 corresponds to our
.paths
method; step 2 corresponds todraw(Set<Paths>)
.)Easily inspect data while debugging
Say you have a complex
Diagram
that isn’t drawing correctly. You drop into the debugger and find where theDiagram
is drawn. How do you find the problem?If you’re using the protocol-oriented approach, you’ll need to create a
TestRenderer
(if it’s available outside your tests) or you’ll need to use a real renderer and actually render somewhere. Inspecting that data will be difficult.But if you’re using the value-oriented approach, you only need to call
paths
to inspect this information. Debuggers can display values much more easily than effects.
The boundary adds another semantic layer, which opens up additional possibilities for testing, transformation, and inspection.
I’ve used this approach on a number of projects and found it immensely helpful. Even with a simple example like the one given here, values have a number of benefits. But those benefits become much more obvious and helpful when working in larger, more complex systems.
If you’d like to see a real world example, check out PersistDB, the Swift persistence library I’ve been working on. The public API presents Query
s, Predicate
s, and Expression
s. These are reduced to SQL.Query
s, SQL.Predicate
s, and SQL.Expression
s. And each of those is reduced to a SQL
, a value representing some actual SQL.