Derex.dev

Subscripts, Custom Operators, and Lazy Evaluation

In the previous post on Protocols, Generics, and Access Boundaries, we built the skeleton of SwiftVault. By replacing a base class with a Storable protocol, our models stayed lightweight value types, and the generic Vault<Element: Storable> stayed fully type-safe.

That foundation does its job, and Phase 2 doesn’t replace it. It refines it.

Phase 1 was deliberately minimal: insert, retrieve, search. It is correct, and the method names are clear. But once you picture the vault living inside a longer-running program, a few rough edges start to matter. Phase 2 is about smoothing them.


What Phase 1 Deliberately Left Out

Here is the whole public surface of the Phase 1 vault:

let vault = Vault<Transaction>()

vault.insert(coffee)                                      // writing
let found = vault.retrieve(id: id)                        // reading
let tagged = vault.findMatching(tagsToMatch: ["coffee"])  // searching

Three areas are worth refining:

AreaPhase 1Phase 2 direction
Size controlItems accumulate until someone clears them.The vault enforces a limit on its own.
Setup costAny helper engine has to be built up front.Helpers are built only when first used.
Call-site feelNamed methods like insert(_:) and retrieve(id:).Familiar collection syntax: brackets and a streaming operator.

And each one gets a matching Swift feature:

AreaSwift featureOne-line job
Size controlProperty observers (willSet / didSet)Run code automatically when a property changes.
Setup costlazy varDon’t build it until someone asks for it.
Call-site feelSubscripts and custom operatorsTeach the vault to respond to [ ] and to <<<.

A note on what kind of thing Vault is

Throughout this post, Vault is a cache, not the only home of your data. That distinction is what makes automatic cleanup safe: if the vault drops an old entry, the original still exists elsewhere and can be fetched again. (If the vault were the only copy of something important, silently dropping entries would be a bug, not a feature.)

Does an unbounded cache actually hurt? Not always. A short script holding a few thousand small values will never notice. It becomes a real concern when three things combine: the keys are open-ended (for example, request IDs), the program runs for a long time, and memory is limited, as on a phone or in a small container. Phase 2 prepares Vault for that situation.

To make this concrete, the vault now takes a maxCapacity. When it is full, the oldest items are evicted. That one rule drives sections 1 and 2.


1. Size Control: Property Observers

A passive container holds data and trusts its callers to keep it valid.

Imagine maxCapacity is 100 and an administrator lowers it to 50 at runtime. A passive vault does nothing. Someone must remember to call a cleanup function right after changing the property, and if that call is missed in one place, the app quietly exceeds its memory budget.

A reactive container removes that burden by attaching the cleanup to the property itself:

var maxCapacity: Int {
    willSet {
        print("About to change capacity from \(maxCapacity) to \(newValue)")
    }
    didSet {
        pruneIfNeeded()
    }
}

The two hooks differ only in when they run:

HookRunsProperty holdsThe other valueGood for
willSetjust before the changethe old valuenewValue is the incoming onelogging, preparing for a transition
didSetjust after the changethe new valueoldValue is the previous oneenforcing rules, side effects

Now vault.maxCapacity = 1 shrinks the vault automatically. No caller has to coordinate anything.

Three details that make it robust

Wiring up didSet is the easy part. A few more decisions make it dependable.

Enforce the limit on insert, too. Observers only fire when the property changes. If capacity is 3 and you insert a 4th item, nothing changed, so didSet stays silent. The fix is to also call the same pruning logic from insert.

Reject invalid capacities. If eviction is written as while storage.count > maxCapacity, a capacity of -1 can never be satisfied, and the loop never ends. Validating the value up front removes the risk.

Make “oldest” unambiguous. If eviction compares createdAt, two items created at the same instant tie. Giving each insertion a sequence number means “oldest” is always exactly “inserted earliest”.

Here is the vault with all of that in place:

final class Vault<Element: Storable> {
    fileprivate var storage: [Element.ID: Element] = [:]
    fileprivate var sequence: [Element.ID: Int] = [:]   // insertion order, so no ties
    private var nextSequence = 0
    private(set) var totalInsertions: Int = 0

    var maxCapacity: Int {
        willSet {
            print("⚠️ [Capacity] Changing capacity from \(maxCapacity) to \(newValue)")
        }
        didSet {
            guard maxCapacity >= 1 else {
                maxCapacity = oldValue          // reject invalid values
                return
            }
            pruneIfNeeded()
        }
    }

    init(maxCapacity: Int = 100) {
        self.maxCapacity = max(1, maxCapacity)  // observers don't run in init, so validate here
    }

    func insert(_ item: Element) {
        storage[item.id] = item
        sequence[item.id] = nextSequence
        nextSequence += 1
        totalInsertions += 1
        pruneIfNeeded()                         // enforce the limit on insert too
    }

    fileprivate func remove(id: Element.ID) {
        storage.removeValue(forKey: id)
        sequence.removeValue(forKey: id)
    }

    private func pruneIfNeeded() {
        let overflow = storage.count - maxCapacity
        guard overflow > 0 else { return }

        let oldestIDs = sequence
            .sorted { $0.value < $1.value }     // lowest sequence number = oldest
            .prefix(overflow)
            .map { $0.key }

        oldestIDs.forEach(remove(id:))
    }
}

Three things to understand in that code:

  • Assigning to a property inside its own didSet does not re-trigger the observers. That is why maxCapacity = oldValue can safely roll back an invalid change without looping.
  • Observers do not run during init. Swift skips them while the object is still being set up, which is why init clamps the value itself with max(1, maxCapacity).
  • Observers do run for any later change, including changes made by the class’s own methods. They are not limited to outside callers.

Other ways to manage a cache

Evicting the oldest insertion is the simplest policy, and a good fit for learning. Real systems often choose differently:

  • LRU evicts what was used longest ago, rather than what was added first.
  • TTL expires entries after a fixed time, which suits data that goes stale.
  • NSCache is Apple’s built-in option, and for most production iOS or macOS caching it is the one you would reach for.

Building our own is how we learn what those tools do for us.


2. Setup Cost: lazy var

Not every capability needs to be paid for up front. Suppose we add a diagnostics engine that inspects storage and prints summaries:

final class DiagnosticReport {
    let generatedAt: Date

    init() {
        print("⚙️ [Heavy Setup] Initializing DiagnosticReport engine...")
        self.generatedAt = Date()
    }

    func printSummary<T: Storable>(for items: [T]) {
        print("📊 [Report] Storing \(items.count) elements.")
    }
}

If its init is expensive, for instance reading from disk or allocating large buffers, then creating one inside Vault.init() makes every vault pay that cost, including a quick throwaway vault that never needs a report. (If setup costs next to nothing, lazy isn’t needed. It earns its keep when initialization is genuinely heavy.)

Marking the property lazy defers the work until the first read:

final class Vault<Element: Storable> {
    // ...
    lazy var diagnostics = DiagnosticReport()
}

Think of a restaurant that doesn’t cook your meal until you order. The ⚙️ [Heavy Setup] message won’t appear when the vault is created. It appears the first time someone reads vault.diagnostics, and never again after that, because the result is stored.

Three rules and caveats:

  • It must be var, never let. The property starts out empty and is filled in after the object exists. That is a mutation, which let forbids.
  • The initializer can use self. A normal stored property can’t refer to self in its default value, because the object isn’t finished yet. A lazy one only runs after the object is fully built, so it can. When written as a closure, like lazy var x: T = { ... }(), that closure runs immediately and is thrown away, so it doesn’t create a retain cycle.
  • lazy is not thread-safe. If two threads read an uninitialized lazy property at the same moment, it can be initialized twice. That is fine in this single-threaded demo, but worth remembering as the series moves toward concurrency.

3. Call-Site Feel: Subscripts and Custom Operators

insert(_:) and retrieve(id:) are perfectly good names. They are clear and explicit. But Vault is, at heart, a keyed collection, and Swift developers already carry muscle memory for that shape from Dictionary. Leaning on those idioms lowers the learning curve for anyone using the type. The named methods stay available. We are adding a more familiar front door, not removing the old one.

Subscripts

A subscript teaches your type to respond to [ ]:

extension Vault {
    subscript(id: Element.ID) -> Element? {
        get {
            storage[id]
        }
        set {
            if let newValue {
                insert(newValue)
            } else {
                remove(id: id)
            }
        }
    }
}

It copies the Dictionary contract, so it is instantly recognizable:

You writeWhat happens
vault[tx.id]Returns the item, or nil if it isn’t there.
vault[tx.id] = txInserts or updates the item.
vault[tx.id] = nilRemoves the item.

Notice the setter calls our own insert and remove, so subscript writes get capacity enforcement for free.

Subscripts can also be overloaded with labels for domain-specific queries:

extension Vault {
    subscript(tag tag: String) -> [Element] {
        storage.values.filter { $0.matches(tag: tag) }
    }
}

let coffeeItems = vault[tag: "coffee"]

The first tag is the label you type at the call site. The second is the parameter name used inside the body. Because the label and argument type differ from the ID subscript, the compiler always knows which one you mean.

Custom operators

When ingesting many items, repeated insert calls or for loops add noise. Swift lets us declare an operator that reads like data flowing into the vault. There are three steps:

// 1. Declare it. "infix" = it sits between two values.
//    The precedence group decides how it mixes with other operators.
infix operator <<<: AdditionPrecedence

// 2. Define what it does, as static methods.
extension Vault {
    @discardableResult
    static func <<< <S: Sequence>(vault: Vault, items: S) -> Vault where S.Element == Element {
        items.forEach { vault.insert($0) }
        return vault
    }

    @discardableResult
    static func <<< (vault: Vault, item: Element) -> Vault {
        vault.insert(item)
        return vault
    }
}

// 3. Use it.
vault <<< [tx1, tx2, tx3]
vault <<< singleTransaction
vault <<< [tx1, tx2] <<< tx3        // chaining works because each call returns the vault

A few notes on the design:

  • The compiler picks the overload from the types on each side. An array on the right calls the first version, a single Element calls the second. This is resolved at compile time.
  • Returning the vault is what enables chaining. AdditionPrecedence is left-associative, so a <<< b <<< c reads as (a <<< b) <<< c. @discardableResult stops the compiler from warning when you ignore the return value.
  • The Sequence constraint mirrors findMatching from Phase 1. Arrays, sets, and any custom sequence work without conversion.

A caution: custom operators are easy to overuse, because a cryptic symbol hides intent. <<< earns its place only because it clearly suggests “stream into”. If a reader would have to look it up, a named method is the better choice.


Putting Phase 2 to Work

Here is everything together, with the vault capped at 3 items:

func runPhase2Demo() {
    print("--- 1. Custom operator (<<<) ---")
    let vault = Vault<Transaction>(maxCapacity: 3)

    let t1 = Transaction(amount: 10.0, tags: ["coffee"])
    let t2 = Transaction(amount: 50.0, tags: ["groceries"])
    let t3 = Transaction(amount: 120.0, tags: ["tech"])

    vault <<< [t1, t2, t3]
    print("Total insertions: \(vault.totalInsertions)")        // 3

    print("\n--- 2. Subscripts ---")
    if let item = vault[t1.id] {
        print("Found transaction: $\(item.amount)")            // $10.0
    }
    print("Tagged 'coffee': \(vault[tag: "coffee"].count)")    // 1

    vault[t1.id] = nil
    print("After nil assignment: \(String(describing: vault[t1.id]))")  // nil

    print("\n--- 3. Capacity enforcement ---")
    let t4 = Transaction(amount: 15.0, tags: ["coffee"])
    let t5 = Transaction(amount: 25.0, tags: ["books"])
    let t6 = Transaction(amount: 99.0, tags: ["tech"])
    vault <<< [t4, t5, t6]
    // The vault never exceeds 3 items: inserting t5 evicted t2, inserting t6 evicted t3.
    print("Items held: \(vault.allItemsSortedByAge.count)")    // 3

    vault.maxCapacity = 1       // willSet logs, didSet prunes, only t6 survives
    print("Items held: \(vault.allItemsSortedByAge.count)")    // 1

    print("\n--- 4. Lazy initialization ---")
    // "Heavy Setup" has not printed yet. It prints on this first access.
    vault.diagnostics.printSummary(for: vault[tag: "tech"])
}

runPhase2Demo()

Trace it step by step to see each feature do its job:

StepState afterWhy
vault <<< [t1, t2, t3]t1, t2, t3 (3 items)At capacity, nothing evicted yet.
vault[t1.id] = nilt2, t3The subscript setter removed t1.
vault <<< [t4, t5, t6]t4, t5, t6Each insert past 3 evicts the oldest: t2, then t3.
vault.maxCapacity = 1t6didSet pruned t4 and t5.
vault.diagnosticst6DiagnosticReport is created only now.

How Phase 2 Builds on Phase 1

Phase 1 foundationPhase 2 refinement
Items stay until clearedmaxCapacity with didSet pruning, plus enforcement on every insert
Callers handle cleanupThe property manages its own limit
Capacity wasn’t a concept yetValidated in init and didSet
Age was a timestampInsertion sequence numbers make “oldest” unambiguous
Helpers built up frontlazy var defers the cost
insert(_:) / retrieve(id:)Subscripts (vault[id], vault[tag:]) alongside them
Loops for bulk ingestionA chainable <<< operator

Next Steps

SwiftVault is now a self-regulating, expressive container. The type system guarantees correctness, access levels protect internal state, observers enforce limits, and subscripts remove syntactic ceremony.

One thing hasn’t changed: Vault is still a class, a reference type. If you hand the same vault to two consumers, or to an asynchronous task, they share one instance, and a change made in one place shows up everywhere. That is also why the lazy caveat above matters.

In Phase 3, we dive into Memory Management (ARC) and a Value Semantics Deep-Dive (Days 16 & 17). We will look at retain cycles and break them with weak and unowned references, then use isKnownUniquelyReferenced to build a snapshot system with true Copy-on-Write (CoW).


If you want to pull down the code and run the Phase 2 demo yourself, the complete, runnable source for SwiftVault is available in this GitHub Gist.

Did I make a mistake? Please considerSend Email With Subject