Variations on a Theme

January 4, 2017

In Weak References and Type Erasure I wrote about using closures, weak capture, and an isLivePredicate to combine type erasure and weak references.

The goal was to store a heterogeneous collection of generic Signal objects while letting them deallocate when client code was done with them. My final implementation used a SignalHolder struct with a couple of closures inside. One closure extracted a value from the current state of the data model and passed that value to a weakly captured generic signal. The other closure weakly captured the same signal and returned whether the signal was still non-nil. I used that isLivePredicate machinery to discard signal holders when their signals deallocated.

I had a nagging doubt that my solution was too clever. James Savage and Joe Groff replied on Twitter with a couple of other approaches that I thought were worth sharing.

Using NSHashTable

James suggested using NSHashTable to eliminate the need for my isLivePredicate machinery. We still need a signal holder for type erasure, but if we can tie the lifetime of the signal holder to that of the signal, we get removal of expired signal holders for free.

To tie the lifetimes together, we can make Signal hold a strong reference to its associated signal holder:

public class Signal {
    fileprivate var keepAliveReference: SignalHolderClass?

    // … continued as in previous post …
}

Because we’re going to put signal holders in an NSHashTable, we need to make them class instances instead of structs. They also lose the isLivePredicate machinery, but are otherwise unchanged:

class SignalHolderClass {
    private let caller: (DataStorage) -> Void
    
    init(
        signal: Signal,
        matching matcher: @escaping (DataStorage) -> Value?)
    {
        caller = { [weak signal] storage in
            guard let signal = signal else { return }
            if let value = matcher(storage) {
                signal.update(to: value)
            }
        }
    }
    
    func update(from storage: DataStorage) {
        caller(storage)
    }
}

Inside our data model, signalHolders becomes a hash table:

private let signalHolders = NSHashTable(options: [.objectPointerPersonality, .weakMemory])

NSHashTable has set semantics. The first option to NSHashTable’s initializer says that we want to compare objects by pointers. That’s the right semantics here, and thankfully so, because there’s no good way to do equality comparison on the contents of SignalHolderClass instances — closures aren’t Equatable.

The second option to the initializer says that the signal holders should be held weakly. The only strong reference to a signal holder is from the signal that it wraps.

The addSignal(_:, matching:) method is essentially unchanged in James’s approach, except that NSHashTable uses add() instead of append():

func addSignal(
    _ signal: Signal,
    matching matcher: @escaping (DataStorage) -> Value?)
{
    let holder = SignalHolderClass(signal: signal, matching: matcher)
    signalHolders.add(holder)
}

By eliminating the isLivePredicate machinery, the didSet handler for storage is straightforward:

var storage: DataStorage {
    didSet {
        for holder in signalHolders.allObjects {
            holder.update(from: storage)
        }
    }
}

The management of object lifetimes in this approach is more Cocoa-like. The pattern of cyclic references — a strong reference from Signal to SignalHolderClass and a weak reference back — is meat-and-potatoes stuff to any Cocoa developer familiar with the delegate pattern. One slight downside is that signalHolders now stores an object type instead of a value-typed Swift collection, but that’s really just an issue for purists. The collection is private to the data model instance and is never shared.

Unfortunately, there’s one deal breaker for my actual project. The Signal class is in a different framework than my data model and SignalHolderClass. So the keepAliveReference we added to Signal above would have to be implemented in an extension using a computed property and associated objects. Alternatively, we could jump through some hoops to move SignalHolderClass into the same framework as Signal without creating a mutual dependency via DataStorage, but I suspect that complexity is greater than the savings of eliminating the isLivePredicate machinery.

Using a Protocol

Joe approached the problem from the opposite direction. Instead of eliminating the isLivePredicate machinery, he suggested using a protocol to simplify the type erasure.

First we make SignalHolder a protocol:

private protocol SignalHolder {
    var isLive: Bool { get }
    func update(from storage: DataStorage)
}

This lets the signal holder storage in our data model just be an array of protocol witnesses:

private var signalHolders: [SignalHolder] = []

Next we make the old signal holder struct generic:

private struct SignalHolderImpl: SignalHolder {
    private let matcher: (DataStorage) -> Value?
    private weak var signal: Signal?
    
    init(
        signal: Signal,
        matching matcher: @escaping (DataStorage) -> Value?)
    {
        self.matcher = matcher
        self.signal = signal
    }
    
    var isLive: Bool { return signal != nil }
    
    func update(from storage: DataStorage) {
        guard let signal = signal else { return }
        if let value = matcher(storage) {
            signal.update(to: value)
        }
    }
}

Instead of storing type-erased closures, SignalHolderImpl directly stores the matching function and a weak reference to the signal. The bodies of the old caller and isLivePredicate closures move into the update(from:) method and isLive computed property respectively.

The addSignal(_:, matching:) method is unchanged from my implementation, as is the didSet handler for storage.

The downside of this approach is that we have to introduce an additional protocol and make the signal holder generic. That’s additional type system complexity. The huge win is that we stop using closures for type erasure. I’m certain that the protocol-oriented approach will be more familiar than nested closures to future developers coming to this code (no matter how much the old Schemer in me likes function composition).

It may also be the case that bouncing through the protocol witnesses to update all the signal holders is less performant than my closure-based approach. That’s not really a concern at all in this code. The number of active signals is limited in my project.

Epilogue

Practically speaking, I think all three approaches to this problem have about the same complexity. Maybe this is as simple as a solution can be here. Based on Swiftiness and the practical concerns regarding separate frameworks, I plan to convert my project to Joe’s SignalHolder protocol approach.

Thanks to James and Joe for their ideas and conversation. It’s good to be reminded to use both the tools that came before and the new hotness when approaching a problem.