Promise Lifetime Question
I’m starting to explore using Promises more in Swift, especially after reading Soroush Khanlou’s posts about them. I like the idea a lot, but I have some questions about best practices for managing the lifetimes of related objects (for specific use-cases).
Motivating Example
I’m trying to wrap some preexisting Cocoa API that uses the delegate pattern for callbacks, and
making an API that returns a Promise
.
Let’s say you have this API that uses a delegate for callback from some async operation:
protocol ExampleAPIDelegate: NSObjectProtocol {
func workCompleted(result: String)
}
class ExampleAPI {
static var sharedInstance: ExampleAPI
weak var delegate: ExampleAPIDelegate?
/// The result of this async work comes via the delegate callback.
func startWork() { … }
}
Here’s how you might “wrap” it, so that you can deal with it by using a Promise
instead:
/// The enum is just for namespacing
enum ExampleWrapper {
/// Private helper to serve as the delegate
private class DelegateShim: ExampleAPIDelegate {
let fulfill: (String) -> Void
let reject: (Error) -> Void
init (fulfill: @escaping (String) -> Void, reject: @escaping (Error) -> Void) {
self.fulfill = fulfill
self.reject = reject
}
func workCompleted(result: String) {
fulfill(result)
}
}
static func promisedWorkResult() -> Promise<String> {
return Promise(work: { (fulfill, reject) in
let shim = DelegateShim(fulfill: fulfill, reject: reject)
ExampleAPI.sharedInstance.delegate = shim
ExampleAPI.sharedInstance.startWork()
})
}
}
Which is nice to use:
ExampleWrapper.promisedWorkResult().then { result in
print("got \(result)")
}
Specifically, the advantages are:
- You don’t need to conform to the delegate protocol (and thus be an
NSObject
subclass) to get the result. - You can put the code that handles the result directly in-line, rather than in a separate method.
The Problem: DelegateShim’s lifetime
But, there is still a memory management problem with this specific example. Nothing is keeping a
strong reference to the DelegateShim
object, aside from work
closure. But that closure
completes (and releases its references) before the delegate callback comes (when the async
ExampleAPI work finishes). So, the DelegateShim
object is gone by that point.
Possible Solutions
One thought I had was to make a subclass of
Promise
, specific for this use case, and have that hold theDelegateShim
object as a property. But, Soroush’s implementation of Promise is afinal class
, so I couldn’t subclass it. That could be changed, but it gave me pause, because I’m not sure if making itfinal
was arbitrary, or if there really is a good reason to force you never to subclass it.Another idea would be to use
objc_set_associated_object()
for the promise and delegateShim, but that seems very unappealing, especially because it ties us back to all the ObjC runtime andNSObject
baggage that I’m trying to avoid.If the
work
block (passed toPromise.init(work:)
was held until the Promise fulfilled, that would solve this issue. The local reference to theDelegateShim
in that closure would be enough to keep it alive until it’s fulfilled. But, that requires a change to thePromise
code. And, maybe that isn’t good in most other use cases for Promises? Maybe it makes it too easy to create reference cycles and leak objects?If we want to leave Promise as-is, we can manually approximate idea #3 by blocking at the end of the
work
closure, so it doesn’t return until we’re done. For example, we could wait on a semaphore that we don’t signal until after the promise has been fulfilled. That would look like this:
enum ExampleWrapper {
private class DelegateShim: ExampleAPIDelegate {
let fulfill: (String) -> Void
let reject: (Error) -> Void
/// Use this to block the `work` closure until the delegate callback comes, to keep the
/// DelegateShim alive.
let doneSemaphore = DispatchSemaphore(value: 0)
init (fulfill: @escaping (String) -> Void, reject: @escaping (Error) -> Void) {
self.fulfill = fulfill
self.reject = reject
}
func somethingHappened(event: String) {
fulfill(event)
doneSemaphore.signal()
}
}
func start() -> Promise<String> {
return Promise(work: { (fulfill, reject) in
let shim = DelegateShim(fulfill: fulfill, reject: reject)
ExampleAPI.sharedInstance.delegate = shim
ExampleAPI.sharedInstance.start()
shim.doneSemaphore.wait() // <-- This makes this block not exit until the semaphore is signaled
})
}
}
Feedback Welcome
Are there other ideas that I’m missing? Is there already a common pattern that I should use here? Or, am I “holding it wrong”, for whatever reason? Let me know!