This article is also available in Chinese.
Last week, I wrote about Promises, which are a high-level building blocks for dealing with asynchronous operations. Using just the fulfill()
, reject()
, and then()
, functions, we can build up lots of functionality in a simple and composable way. I’d like to explore some of those here.
Promise.all
Probably the poster child for the value of promisifying all of your asynchronous callbacks is Promise.all
. What this static function does is wait for all the promises you give it to fulfill, and once they have, Promises.all
will fulfill itself with the array of all fulfilled values. For example, you might want to write code to hit an API endpoint once for each item in an array. map
and Promise.all
make that super easy:
let userPromises = users.map({ user in
APIClient.followUser(user)
})
Promise.all(userPromises).then({
//all the users are now followed!
}).onFailure({ error in
//one of the API requests failed
})
To write Promise.all
, we first need to create a new Promise the represents the state of all the promises combined. If the array is empty, we can fulfill immediately.
static func all<T>(promises: [Promise<T>]) -> Promise<[T]> {
return Promise<[T]>(work: { fulfill, reject in
guard !promises.isEmpty else { fulfill([]); return }
})
}
Inside this promise, we need to loop through each promise and add a handler for success and one for failure. If any of the promises have failed, we can reject
the larger promise.
for promise in promises {
promise.then({ value in
}).onFailure({ error in
reject(error)
})
}
Only when all of the promises are complete should it fulfill
the larger promise. With a simple check to make sure none of the promises are rejected or pending and a little flatMap
magic, we can fulfill the promise with the values of all the promises combined. The whole function together looks like this:
static func all<T>(promises: [Promise<T>]) -> Promise<[T]> {
return Promise<[T]>(work: { fulfill, reject in
guard !promises.isEmpty else { fulfill([]); return }
for promise in promises {
promise.then({ value in
if !promises.contains({ $0.isRejected || $0.isPending }) {
fulfill(promises.flatMap({ $0.value }))
}
}).onFailure({ error in
reject(error)
})
}
})
}
Note that promises can only be fulfilled or rejected once. If fulfill
or reject
were called a second time, it wouldn’t have any effect on the state of the promise.
Promises, like NSOperations, are state machines, but they store all the important state representing their completion in a thread-safe way. This is a different approach than NSOperation
, where you have to provide your own state, storage, and thread-safety mechanisms. Operations don’t store the resulting value, so you have to manage that yourself too.
NSOperation
also holds data about threading models and priority order, whereas promises don’t make any guarantees about how the work will be completed, just that it will be completed. This is borne out by looking at the Promise class itself. Its only instance variables are the state
, which holds information about whether it’s pending, fulfilled, or rejected (and the corresponding data), and an array of callbacks. (It also holds an isolation queue, but that’s not really state.)
delay
One useful promise is one that resolves itself after some delay.
static func delay(delay: NSTimeInterval) -> Promise<()> {
return Promise<()>(work: { fulfill, reject in
let nanoseconds = Int64(delay*Double(NSEC_PER_SEC))
let time = dispatch_time(DISPATCH_TIME_NOW, nanoseconds)
dispatch_after(time, dispatch_get_main_queue(), {
fulfill(())
})
})
}
Internally, this could be implemented with usleep
or some other method of delaying, but dispatch_after
is simple enough for our usage. This promise will become more useful as we build up other interesting promises.
timeout
Next, we’ll use delay
to build timeout
. This promise will be rejected after a delay.
static func timeout<T>(timeout: NSTimeInterval) -> Promise<T> {
return Promise<T>(work: { fulfill, reject in
delay(timeout).then({ _ in
reject(NSError(domain: "com.khanlou.Promise", code: -1111, userInfo: [ NSLocalizedDescriptionKey: "Timed out" ]))
})
})
}
This isn’t that useful of a promise by itself, but it will be useful in building a few of the other behaviors that we want.
race
The partner to Promise.all
, which fulfills when all promises are fulfilled, is Promise.race
, which fulfills or rejects with the first promise that completes.
static func race<T>(promises: [Promise<T>]) -> Promise<T> {
return Promise<T>(work: { fulfill, reject in
guard !promises.isEmpty else { fatalError() }
for promise in promises {
promise.then({ value in
fulfill(value)
}).onFailure({ error in
reject(error)
})
}
})
}
Because promises can only be fulfilled or rejected once, calling fulfill
or reject
on the outer promise after it’s already been moved out of the .Pending
state won’t have any effect.
With this function, timeout
, and Promise.race
, we can now create a new promise that either succeeds, fails, or times out within a certain limit. We’ll make it an extension on Promise
.
extension Promise {
func addTimeout(timeout: NSTimeInterval) -> Promise<Value> {
return Promise.race([self, Promise.timeout(timeout)])
}
}
It can be used within a normal promise chain, like so:
APIClient
.getUsers()
.addTimeout(0.5)
.then({
//we got our users within 0.5 seconds
})
.onFailure({ error in
//maybe our timeout error, maybe a network error
})
One of the reasons I like promises so much is that their composability lets us build up behaviors easily. Promises usually require a guarantee that they will be fulfilled or rejected at some point, but our timeout function allows us to correct that behavior in a general fashion.
recover
recover
is another useful function. It lets us catch an error and easily recover from it without messing up the rest of the promise chain.
We know the form we want our function to take: it should accept a function that takes our error and returns a new promise. Our method will also return a promise that we can use to continue chaining off of.
extension Promise {
func recover<T>(recovery: (ErrorType) -> Promise<T>) -> Promise<T> {
}
}
For the body of the method, we know that we need to return a new promise, and if the current promise (self
) succeeds, we should forward that success to the new promise.
func recover<T>(recovery: (ErrorType) -> Promise<T>) -> Promise<T> {
return Promise(work: { fulfill, reject in
self.then({ value in
fulfill(value)
}).onFailure({ error in
}
}
}
onFailure
, however, is a different story. If the promise fails, we should call the recovery
function that was provided. That will give us a new promise. If that recovery promise succeeds, we can pass on that success to the new promise, and same if it fails.
//..
}).onFailure({ error in
recovery(error).then({ value in
fulfill(value)
}).onFailure({ error in
reject(error)
}
})
//...
The completed function looks like this:
extension Promise {
func recover(recovery: (ErrorType) -> Promise<Value>) -> Promise<Value> {
return Promise(work: { fulfill, reject in
self.then({ value in
fulfill(value)
}).onFailure({ error in
recovery(error).then({ value in
fulfill(value)
}).onFailure({ error in
reject(error)
})
})
})
}
}
With this new function, we can recover from errors. For example. if the network doesn’t load the data we expect to see, we can load the data from a cache:
APIClient.getUsers()
.recover({ error in
return cache.getUsers()
}).then({ user in
//update UI
}).onFailure({ error in
//handle error
})
retry
Retrying is another capability we can add. To retry, we need a number of times to retry and a function that will generate the promise to be retried (so we can create the promise multiple times).
static func retry<T>(count count: Int, delay: NSTimeInterval, generate: () -> Promise<T>) -> Promise<T> {
if count <= 0 {
return generate()
}
return Promise<T>(work: { fulfill, reject in
generate().then({ value in
fulfill(value)
}).recover({ error in
return self.delay(delay).then({
retry(count: count-1, delay: delay, generate: generate)
})
}).onFailure({ error in
reject(error)
})
})
}
- If the count is 1 or less, just generate the promise and return it.
- Otherwise, create a new promise which generates the promise, and recovers it with a
delay
followed by a retry ofcount-1
.
Creating retry builds on both delay
and recover
, which we wrote above.
In each of these examples, small, composable pieces come together to make simple and elegant solutions. All of these behaviors are built on the simple .then
and .onFailure
functions provided by the core of the promise implementation. Simply by formalizing what completion blocks look like, we can solve problems like timeouts, recovery, and retrying, and we can do so in a simple and reusable way. These examples still need some tests and verification, but I’ll be adding them slowly to the GitHub repo in the coming days and weeks.