Variations on a Theme
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.