Lab 1.C — Protocol-oriented calculator
Goal: Build a small arithmetic library where every operation is its own struct conforming to a BinaryOperation protocol. You’ll feel — in your hands — why “protocol + struct + extension” is the modern Swift design idiom.
Time budget: 45–60 minutes.
Prerequisites: Ch 1.6, Ch 1.7, Ch 1.8.
What you’ll build
let result = try Calculator.evaluate("3 + 4 * 2")
// 11
let ops: [any BinaryOperation] = [Add(), Subtract(), Multiply(), Divide()]
for op in ops { print(op.symbol, op.apply(6, 2)) }
// + 8 / - 4 / * 12 / ÷ 3
A tiny evaluator (~80 lines), protocols all the way down.
Step 1 — The protocol
In a new SwiftPM library or a playground:
protocol BinaryOperation {
/// The symbol used in expressions: "+", "-", "*", "/".
var symbol: Character { get }
/// Operator precedence. Higher binds tighter.
var precedence: Int { get }
/// Perform the operation on two operands.
func apply(_ lhs: Double, _ rhs: Double) throws -> Double
}
The protocol describes what an operation does and exposes enough metadata for the evaluator to do its job (precedence parsing) — without the protocol knowing anything about how each operation is implemented.
Step 2 — Concrete operations
struct Add: BinaryOperation {
let symbol: Character = "+"
let precedence = 1
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs + rhs }
}
struct Subtract: BinaryOperation {
let symbol: Character = "-"
let precedence = 1
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs - rhs }
}
struct Multiply: BinaryOperation {
let symbol: Character = "*"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) -> Double { lhs * rhs }
}
enum CalcError: Error { case divisionByZero, unknownOperator(Character), badExpression(String) }
struct Divide: BinaryOperation {
let symbol: Character = "/"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) throws -> Double {
guard rhs != 0 else { throw CalcError.divisionByZero }
return lhs / rhs
}
}
Notice: each operation is a value type with a single responsibility. No inheritance, no superclass. To add Modulo, you write 5 lines and don’t touch anyone else’s code.
Step 3 — A registry (protocol extensions in action)
extension BinaryOperation where Self == Add { static var add: Add { Add() } }
extension BinaryOperation where Self == Subtract { static var subtract: Subtract { Subtract() } }
extension BinaryOperation where Self == Multiply { static var multiply: Multiply { Multiply() } }
extension BinaryOperation where Self == Divide { static var divide: Divide { Divide() } }
enum OperationRegistry {
static let all: [any BinaryOperation] = [Add(), Subtract(), Multiply(), Divide()]
static func lookup(_ symbol: Character) -> (any BinaryOperation)? {
all.first { $0.symbol == symbol }
}
}
The extension BinaryOperation where Self == … trick mirrors how SwiftUI’s .padding, .font, etc. are dotted onto the type — a modern protocol-oriented idiom you’ll see throughout Apple’s APIs.
Step 4 — The evaluator (shunting-yard, lite)
enum Calculator {
static func evaluate(_ expression: String) throws -> Double {
let tokens = try tokenize(expression)
let rpn = try toRPN(tokens)
return try evaluateRPN(rpn)
}
enum Token { case number(Double), op(any BinaryOperation) }
static func tokenize(_ s: String) throws -> [Token] {
var tokens: [Token] = []
var i = s.startIndex
while i < s.endIndex {
let ch = s[i]
if ch.isWhitespace { i = s.index(after: i); continue }
if ch.isNumber || ch == "." {
var j = i
while j < s.endIndex, s[j].isNumber || s[j] == "." { j = s.index(after: j) }
guard let n = Double(s[i..<j]) else {
throw CalcError.badExpression("invalid number near \(s[i..<j])")
}
tokens.append(.number(n))
i = j
} else if let op = OperationRegistry.lookup(ch) {
tokens.append(.op(op))
i = s.index(after: i)
} else {
throw CalcError.unknownOperator(ch)
}
}
return tokens
}
static func toRPN(_ tokens: [Token]) throws -> [Token] {
var output: [Token] = []
var ops: [any BinaryOperation] = []
for t in tokens {
switch t {
case .number: output.append(t)
case .op(let op):
while let top = ops.last, top.precedence >= op.precedence {
output.append(.op(ops.removeLast()))
}
ops.append(op)
}
}
while let op = ops.popLast() { output.append(.op(op)) }
return output
}
static func evaluateRPN(_ tokens: [Token]) throws -> Double {
var stack: [Double] = []
for t in tokens {
switch t {
case .number(let n): stack.append(n)
case .op(let op):
guard stack.count >= 2 else { throw CalcError.badExpression("stack underflow") }
let r = stack.removeLast(), l = stack.removeLast()
stack.append(try op.apply(l, r))
}
}
guard stack.count == 1 else { throw CalcError.badExpression("leftover values") }
return stack[0]
}
}
Step 5 — Tests
import XCTest
final class CalculatorTests: XCTestCase {
func test_basic_addition() throws {
XCTAssertEqual(try Calculator.evaluate("3 + 4"), 7)
}
func test_precedence() throws {
XCTAssertEqual(try Calculator.evaluate("3 + 4 * 2"), 11)
}
func test_division_by_zero() {
XCTAssertThrowsError(try Calculator.evaluate("10 / 0")) { err in
XCTAssertEqual(err as? CalcError, .divisionByZero)
}
}
func test_unknown_operator() {
XCTAssertThrowsError(try Calculator.evaluate("3 ^ 2")) { err in
guard case CalcError.unknownOperator("^") = err else {
XCTFail("expected unknownOperator('^'), got \(err)")
return
}
}
}
}
extension CalcError: Equatable {
public static func == (a: CalcError, b: CalcError) -> Bool {
switch (a, b) {
case (.divisionByZero, .divisionByZero): true
case (.unknownOperator(let x), .unknownOperator(let y)): x == y
case (.badExpression(let x), .badExpression(let y)): x == y
default: false
}
}
}
Step 6 — Extend it without changing anyone else’s code
Add a Modulo operation:
struct Modulo: BinaryOperation {
let symbol: Character = "%"
let precedence = 2
func apply(_ lhs: Double, _ rhs: Double) throws -> Double {
guard rhs != 0 else { throw CalcError.divisionByZero }
return lhs.truncatingRemainder(dividingBy: rhs)
}
}
// Add it to the registry — one line.
// Then: Calculator.evaluate("10 % 3") → 1.0
This is the OCP (Open-Closed Principle) win of protocol-oriented design. No existing struct, no existing extension, no existing function had to change.
Done when
- All four tests pass.
-
You added a 5th operation (
Modulo,Power, whatever) by adding one file with no edits elsewhere. -
You can explain to a colleague why each operation is a
struct, not aclass. -
You wrote at least one
extension BinaryOperation where Self == Xand saw it work.
Stretch goals
- Unary operations. Add a
UnaryOperationprotocol and aNegatestruct. The tokenizer will need to disambiguate-3 + 4from5 - 3. - Parentheses. Extend the shunting-yard with
(and). - Generic operand type. Make
BinaryOperationgeneric overOperand: Numericso you can have integer and floating-point operations side by side. You’ll discover why this requiresassociatedtype Operandinstead of a generic parameter. - Pretty-print. Add a
description: Stringrequirement toBinaryOperationand conform each op soprint(op)showsAdd(+, prec=1).
Real-world context
This pattern — protocol describes capability, struct implements one variant, registry holds them all, evaluator looks them up — is exactly how SwiftUI describes view modifiers (each .padding, .background, .frame is a ViewModifier struct), how Charts describes mark types, and how Codable decoders pick strategies. Internalize this lab and you’ve internalized half of Apple’s API design philosophy.
Next lab: 1.D — Async image fetcher