16 KiB
metadata.title = "Simple Swift Promises"
metadata.category = "swift"
metadata.date = "2020-02-18 22:10:42 -0400"
metadata.shortDesc = "Building a rudimentary implementation of asynchronous promises in Swift."
metadata.slug = "simple-swift-promises"
Recently, I've been working on cleaning up the networking code in Tusker, my iOS client for Mastodon/Pleroma and I briefly played around with using the new Combine framework as well as the built in URLSession.DataTaskPublisher
helper. Combine, much like SwiftUI, uses Swift's incredibly powerful type system to great advantage because it's a Swift-only framework. It's quite efficient, but because there are so many generic types and implementations of different protocols, the API (in my experience) isn't the most pleasant to work with. I was thinking about other asynchronous programming schemes and the one that came to mind as being the nicest to use was JavaScript's Promises. It has a fairly simple API, so I started wondering how much work it would be to build something similar in Swift. Turns out: not that much.
Be warned, this code isn't great. It's the result of a few hours of fiddling around trying to build something, not the best possible solution.
To start off with, there's a Promise
class that's generic over its result type. It stores 1) a list of closures that will be invoked when it is resolved and 2) the resolved result (or nil
, if the promise hasn't yet been resolved). There's a helper function that resolves the promise by storing the result and invokes any already-added completion handlers with the result. There's another function that's called to add a handler to the promise, storing it if the promise hasn't been resolved and invoking it immediately if it has.
public class Promise<Result> {
private var handlers: [(Result) -> Void] = []
private var result: Result?
func resolve(_ result: Result) {
self.result = result
self.handlers.forEach { $0(result) }
}
func addHandler(_ handler: @escaping (Result) -> Void) {
if let result = result {
handler(result)
} else {
handlers.append(handler)
}
}
}
To keep things clean, everything in the public API is implemented in a public extension on Promise
. To start with, the most primitive way of constructing a promise. The initializer takes a closure (resultProvider
) which itself receives as an argument a closure that takes a Result
. In the initializer, the result provider is immediately invoked passing the self.resolve
helper function from earlier. This will kick off whatever potentially long-running/asynchronous task is being wrapped in a promise. Once the task has completed, it will call the closure passed in as the resolve
parameter with whatever value it ultimately got.
public extension Promise {
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void) -> Void) {
self.init()
resultProvider(self.resolve)
}
}
Using it might be something like this:
let promise = Promise<String> { (resolve) in
performLongOperation() { (result) in
resolve(result)
}
}
With this in place, the first helper function can be implemented. It will take a single value and produce a promise that has that is resolved with that value:
public extension Promise {
static func resolve<Result>(_ value: Result) -> Promise<Result> {
let promise = Promise<Result>()
promise.resolve(value)
return promise
}
}
Using it is as simple as Promise.resolve("blah")
. (The only reason this is a static method instead of just another convenience initializer on Promise is to match the JavaScript API that it's modeled after.)
Next up, there needs to be a way of adding a completion block to a promise. There are a couple different possibilities for using this and each will be implemented slightly differently.
The first and simplest is adding a completion block that receives the result of the promise and doesn't return anything. Another then
implementation takes a closure that receives the value of the promise and produces a new value, resulting in a promise that produces the new value. Finally, there's one that takes a closure which produces a promise of a new value, resulting in a promise that returns the new value.
public extension Promise {
@discardableResult
func then(_ fn: @escaping (Result) -> Void) -> Promise<Result> {
addHandler(fn)
return self
}
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
}
return next
}
}
In the simplest case, the promise can simply add the handler to itself and return itself for other uses. This is marked with @discardableResult
because the API user should be able to add a completion handler without causing a unnecessary compile-time warning.
When given a closure that produces a value, then
should return a new promise that's for the type of the result of the closure. To achieve this, the then
function is generic for the Next
type which is both the return type of the closure and the result type of the promise returned by then
. A new promise is constructed, and a completion handler is added to self
to resolve the next promise once self has resolved with the value produced by passing its own result through the mapper function.
Finally, when given a closure that produces a promise, a new promise is also constructed and a completion handler added to the current promise. This time, when the parent result is passed into the mapper function, it receives back a promise. A completion handler is added to that promise which resolves the next promise with the value it produces, linking the promise returned by then
onto the promise produced by the closure. This version of then
in particular is very powerful, because it allows promises to composed together and sets of nested callbacks collapsed.
And with that, a barebones promises API is born.
Handling Errors
This promise implementation can fairly easily be extended to support handling errors in much the same manner as normal results (a lot of the code will look very familiar).
Promise could be made generic over some failure type as well, but using the plain Swift Error
type makes things a bit simpler.
public class Promise<Result> {
private var catchers: [(Error) -> Void] = []
private var error: Error?
func reject(_ error: Error) {
self.error = error
self.catchers.forEach { $0(error) }
}
func addCatcher(_ catcher: @escaping (Error) -> Void) {
if let error = error {
catcher(error)
} else {
catchers.append(catcher)
}
}
}
Similarly to the normal promise resolution stuff, the Promise class stores a list of functions which handle any error as well as the error itself, if one's already been produced. There's also a reject
internal helper function which is called to reject the promise, storing the error and passing it to any already-registered catch functions. Also paralleling the addHandler
method, there's an addCatcher
helper which takes a closure that consumes an error, either invoking it immediately if the promise has already been rejected or appending it to the internal array of catcher functions.
The main convenience initializer is also amended to receive a closure that itself takes two closure parameters: functions that respectively resolve and reject the promise. The closure is invoked immediately passing self.resolve
and self.reject
as the resolver and rejecter functions.
public extension Promise {
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
self.init()
resultProvider(self.resolve, self.reject)
}
}
With that in place, a static reject
helper can also be created, which works almost exactly the same as the static resolve
helper. It takes an error and produces a promise that's rejected with that error by immediately invoking the reject
function with that error in the result provider closure.
public extension Promise {
static func reject<Result>(_ error: Error) -> Promise<Result> {
let promise = Promise<Result>()
promise.reject(error)
return promise
}
}
Additionally, the two then
functions that produce new promises are changed to make them reject the next promise when they themself reject. The one that accepts a closure returning a promise is also tweaked so that, when the new promise is received from the closure, the next promise is made to fail if that promise fails.
public extension Promise {
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
addCatcher(next.reject)
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
newPromise.addHandler(next.reject)
}
addCatcher(next.reject)
return next
}
}
Next, for actually handling errors there are public catch
functions on Promise
in the same fashion as then
:
public extension Promise {
@discardableResult
func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise<Result> {
addCatcher(catcher)
return self
}
func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newResult = catcher(error)
next.resolve(newResult)
}
return next
}
func `catch`(_ catcher: @escaping (Error) -> Promise<Result>) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newPromise = catcher(error)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
return next
}
}
The interesting implementations of the catch
function both first add a handler to itself which simply resolves the next promise with the same result. They also add catchers to themselves which invoke the catcher
closure with error produced by the parent and either gets a result back immediately, in which case it resolves the next promise, or gets back a promise for a new result, in which case it adds a handler to the new result promise to either resolves the next promise when the new promise succeeds or reject it if the new promise fails (that is, if a catcher function produces a promise that resolves, the parent's error is resolved, or if it produces a promise that rejects, the parent's error is replaced with the new error).
One difference between these functions and the then
function, is that the result type of the parent promise must be the same as the new promise's result type. This is done because JavaScript promises have the semantic where then
handlers added after a catch
are invoked regardless of whether or not the promise resolved (unless, of course, the catch block produced a rejected promise). This means that the catcher closure must produce a value of the same type as the parent promise, otherwise, there would be a case in which subsequent thens could not be invoked with the actual result value. That makes the following example, in which a potential error is replaced with some default value meaning print
will always be invoked, possible:
longRunningPossiblyRejectingPromise()
.catch { (error) -> String in
// log error
return "default value"
}.then { (str) -> Void in
print(str)
}
Now, the simple promise implementation is capable of handling errors as well.
Finishing Touches
First, because of the way promises are implemented, the queue a then/catch closure is executed on depends solely on the queue on which the previous promise resolved/rejected. To make switching queues easier, a simple helper function can be written that simply passes the result through, just resolving on a different queue.
public extension Promise {
func handle(on queue: DispatchQueue) -> Promise<Result> {
return self.then { (result) in
return Promise { (resolve, reject) in
queue.async {
resolve(result)
}
}
}
}
}
Next, the Promise.all
helper function can be implemented using a DispatchGroup
to take an array of promises with the same result type and create a new promise that resolves to an array of values of the same type as the result type:
public extension Promise {
static func all<Result>(_ promises: [Promise<Result>], queue: DispatchQueue = .main) -> Promise<[Result]> {
let group = DispatchGroup()
var results: [Result?](repeating: nil, count: promises.count)
var firstError: Error?
for (index, promise) in promises.enumerated() {
group.enter()
promise.then { (res) -> Void in
queue.async {
results[index] = res
group.leave()
}
}.catch { (err) -> Void in
if firstError == nil {
firstError = err
}
group.leave()
}
}
return Promise<[Result]> { (resovle, reject) in
group.notify(queue: queue) {
if let firstError = firstError {
reject(firstError)
} else {
resolve(results.compactMap { $0 })
}
}
}
}
}
This method follows the same semantics as the JavaScript equivalent. If any of the individual promises rejects, the all
promise will be rejected with the first error that occurred. It also maintains the order of the results.
Conclusion
Promises can be pretty useful, but they're not without their own pitfalls. Primarily, if you want to use the result of an intermediate promise in one further along the chain, you have to do something like passing it along with every intermediate result in a tuple, which is less than ideal. But, in some specific cases, they can be quite useful.
Consider making a new post in a social media app. First, any selected attachments are uploaded to the server. Then, only after all of those have completed successfully, can the post be made. After the post has completed, the resulting post received back from the API is stored. After that, UI changes can be made on the main thread to indicate that the post has succeeded. And, for all of those steps, there's some common error handling code to show a message to the user. As in the following (simplified) example, this fits fairly well into the model of promises we've constructed.
let attachmentPromises = attachments.map { (attachment) -> Promise<Attachment> in
ApiClient.shared.uploadAttachment(attachment)
}
Promise<[Attachment]>.all(attachmentPromises).then { (attachments) -> Promise<Post> in
ApiClient.shared.createPost(text: self.postText, attachments: attachments)
}.then { (post) -> Post in
ApiObjectCache.shared.store(post)
self.currentDraft?.remove()
return post
}.handle(on: DispatchQueue.main).then { (post) in
self.dimiss(animated: true)
}.catch { (error) -> Void in
let alert = createAlertController(title: "Couldn't Post", message: error.localizedDescription)
self.present(alert, animated: true)
}
As for my own purposes, I don't know whether I'll end up using this or not. It's neat, but it feels like it's verging on an unnecessary abstraction. Either way, it was a fun experiment.
If you want to check out the full code, the project is in a repo on my Gitea (trying to do anything asynchronous in a Swift Playground is painful). I've also made public a branch of Tusker which is using these promises in some places.