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:
| Area | Phase 1 | Phase 2 direction |
|---|---|---|
| Size control | Items accumulate until someone clears them. | The vault enforces a limit on its own. |
| Setup cost | Any helper engine has to be built up front. | Helpers are built only when first used. |
| Call-site feel | Named methods like insert(_:) and retrieve(id:). | Familiar collection syntax: brackets and a streaming operator. |
And each one gets a matching Swift feature:
| Area | Swift feature | One-line job |
|---|---|---|
| Size control | Property observers (willSet / didSet) | Run code automatically when a property changes. |
| Setup cost | lazy var | Don’t build it until someone asks for it. |
| Call-site feel | Subscripts and custom operators | Teach 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:
| Hook | Runs | Property holds | The other value | Good for |
|---|---|---|---|---|
willSet | just before the change | the old value | newValue is the incoming one | logging, preparing for a transition |
didSet | just after the change | the new value | oldValue is the previous one | enforcing 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
didSetdoes not re-trigger the observers. That is whymaxCapacity = oldValuecan 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 whyinitclamps the value itself withmax(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.
NSCacheis 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, neverlet. The property starts out empty and is filled in after the object exists. That is a mutation, whichletforbids. - The initializer can use
self. A normal stored property can’t refer toselfin 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, likelazy var x: T = { ... }(), that closure runs immediately and is thrown away, so it doesn’t create a retain cycle. lazyis 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 write | What happens |
|---|---|
vault[tx.id] | Returns the item, or nil if it isn’t there. |
vault[tx.id] = tx | Inserts or updates the item. |
vault[tx.id] = nil | Removes 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
Elementcalls the second. This is resolved at compile time. - Returning the vault is what enables chaining.
AdditionPrecedenceis left-associative, soa <<< b <<< creads as(a <<< b) <<< c.@discardableResultstops the compiler from warning when you ignore the return value. - The
Sequenceconstraint mirrorsfindMatchingfrom 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:
| Step | State after | Why |
|---|---|---|
vault <<< [t1, t2, t3] | t1, t2, t3 (3 items) | At capacity, nothing evicted yet. |
vault[t1.id] = nil | t2, t3 | The subscript setter removed t1. |
vault <<< [t4, t5, t6] | t4, t5, t6 | Each insert past 3 evicts the oldest: t2, then t3. |
vault.maxCapacity = 1 | t6 | didSet pruned t4 and t5. |
vault.diagnostics | t6 | DiagnosticReport is created only now. |
How Phase 2 Builds on Phase 1
| Phase 1 foundation | Phase 2 refinement |
|---|---|
| Items stay until cleared | maxCapacity with didSet pruning, plus enforcement on every insert |
| Callers handle cleanup | The property manages its own limit |
| Capacity wasn’t a concept yet | Validated in init and didSet |
| Age was a timestamp | Insertion sequence numbers make “oldest” unambiguous |
| Helpers built up front | lazy var defers the cost |
insert(_:) / retrieve(id:) | Subscripts (vault[id], vault[tag:]) alongside them |
| Loops for bulk ingestion | A 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