Menu

PromiseKit and `ensure`

Intro

The essence of functional programming is to express computation in small, self contained units (functions) that we can combine together to get the result. If each unit that we start with doesn't affect other units, we can combine them in all kinds of ways and still easily reason about the outcome. For example, if we have

func f(x: T) -> V {...}
func g(x: V) -> U {...}

We can do g(f(x)) to compute U from a value of type T. With classes, this reverse notation can be expressed more naturally with something like x.f().g() which looks like a chain.

In addition to chaining, we can also combine functions into more complex building blocks. For example,

func h(x: T) -> U { return g(f(x)) }

And then use h just like f and g.

PromiseKit and composition

Usually, chaining is the first thing that comes to mind when we think about Promises. We take a few simple asynchronous tasks and chain them together to get the result that we need. But promises and the way they are combined are also examples of these concepts from functional programming, and it should be possible to combine them to get compound promises that act exactly as the simpler promises that they are made of, assuming that those simpler promises don't have side effects. Having these compound promises can be just as useful as having compound functions -- they allow us to express a more complex computation that we can reuse.

A function doesn't have side effects if we we can call it multiple times without any change to the context in which it's called. Those functions are the easiest to combine. A weaker requirement is if the state changes after calling it once, and then doesn't change more. For example, if a function authenticates a user and changes the app state to "logged in", calling it the next time is a no-op. Or if a function creates and presents a view controller from a specific view controller, calling it more than once doesn't try to present more and more copies. (Here, we assume that presentation takes time due to animation, so it has a result, the returned view controller, and is an asynchronous task that changes the state).

A specific example

So let's say we have a task that creates a view controller, presents it, collects some input from the user, and returns that input. A good way to describe this would be a function that returns Promise<Result>. The implementation would follow all those steps and fulfill the promise when the user enters all the information or cancels the form. In all cases, we want to dismiss the presented view controller to leave the app in the same state where it was before running the promise. This would make it a good Promise citizen because we can then call it without any changes in the app state and can make it part of a compound promise (for example, show the form in different contexts). Naturally, we would want to put the code to dismiss the view controller into the ensure block so that it executes no matter what branch the code takes. And that's where our code would break the "no side effects" rule because by the time the promise is done executing, the presented view controller will be still running the dismiss animation! So if we try to do something UI related in the next promise of the containing chain, we may either miss the animation (which will be jarring to the user), or break the app state.

The problem

Even though the ensure operator returns a promise, it assumes that its closure is synchronous; calls it and then immediately returns the promise that it's being applied to. The assumption is that ensure is expected to run at the end of the chain, so it doesn't need to be asynchronous. This works if ensure is used only in that way but breaks if we want to turn the chain into a reusable (compound) promise.

A solution

Luckily, PromiseKit provides enough primitives to make an alternate version of ensure which waits to end the promise until its closure argument (a promise) is done. Here is one way to implement it:

extension Promise {
/// Boolean state that is used for sharing data between
/// promises. It needs to be a class because a struct would
/// be just copied.
private class BoolBox {
/// The stored value
var value: Bool
init(_ value: Bool) {
self.value = value
}
}
/**
The provided closure executes when this promise resolves.
This variant of `ensure` executes just as any other `then`
clause which allows the provided closure to have
ascynchronous code. Unlike `then`, the closure executes on
either fullill or reject (same as `ensure`). If the closure
is rejected, this rejects the containing chain as well.
- Parameter on: The queue to which the provided closure dispatches.
- Parameter execute: The closure that executes when this promise fulfills.
- Returns: A new promise that resolves when all promises
returned from the provided closure resolve.
*/
public func ensureAsync(on q: DispatchQueue? = conf.Q.return, execute body: @escaping () -> Promise<Void>) -> Promise {
// the state for keeping track whether the body executed
// in `then` or should be executed in `recover` to avoid
// executing it in both places.
let executedBody = BoolBox(false)
return self.then(on: q) { value -> Promise in
// update the state that the body is executed in `then`
executedBody.value = true
return body().then(on: q) { () -> Promise in
// if body is rejected, this rejects the containing
// chain as well
// pass through the resolved value
return .value(value)
}
}
// we have to use `recover` instead of `catch` because `catch`
// cascades -- no `then` block is executed unless `recover` is called
// but we pass through the rejection after the body resolves
.recover(on: q) { (error) -> Promise in
// execute body only if it wasn't executed before.
// If there was an error while executing
// body, this is the error we get here,
// but executedBody is already set to true (since
// that happens before actually executing it), so
// the body is still not executed twice.
if !executedBody.value {
// execute the body, and then pass through the rejected error
return body().then(on: q) { Promise(error: error)}
}
else {
// just pass through the rejected error
return Promise(error: error)
}
}
}
}