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 a class.
  • You wrote at least one extension BinaryOperation where Self == X and saw it work.

Stretch goals

  • Unary operations. Add a UnaryOperation protocol and a Negate struct. The tokenizer will need to disambiguate -3 + 4 from 5 - 3.
  • Parentheses. Extend the shunting-yard with ( and ).
  • Generic operand type. Make BinaryOperation generic over Operand: Numeric so you can have integer and floating-point operations side by side. You’ll discover why this requires associatedtype Operand instead of a generic parameter.
  • Pretty-print. Add a description: String requirement to BinaryOperation and conform each op so print(op) shows Add(+, 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