Promisor
Promisor is an implementation of Promise in Swift.
About Promises
A Promise
represents the eventual result of an asynchronous operation.
Promises are very similar to the promises you make in real life, a promise can either be kept or broken.
Async Programming: From Callbacks to Promises
Before promises, callback-based APIs were commonly used for asynchronous code. Here's an example:
// Callback hell
func doSomethingAsync(parameters: [String: Any], completion: @escaping (Response?, Error?) -> ()) {
validate(parameters) { _, error in
if let error = error {
completion(nil, error)
} else {
self.queryDB(with: parameters) { dbResult, error in
if let error = error {
completion(nil, error)
} else if let dbResult = dbResult {
self.doServiceCall(dbResult) { response, error in
completion(response, error)
}
}
}
}
}
}
The specific pattern of using deeply-nested callbacks in this manner is commonly referred to as "callback hell", because it makes the code less readable and hard to maintain.
Now, with promises:
// Promise
func doSomethingAsync(parameters: [String: Any]) -> Promise<Response> {
return validate(parameters)
.then {
self.queryDB(with: parameters)
}
.then { dbResult in
self.doServiceCall(dbResult)
}
}
Usage
A Promise
is an object representing the eventual completion or failure of an asynchronous operation.
Essentially, a promise is a returned object to which you attach handlers (aka callbacks), instead of passing handlers into a function.
Imagine a function, createAudioFileAsync()
, which asynchronously generates a sound file given a configuration record and two handler functions, one called if the audio file is successfully created, and the other called if an error occurs.
Here's some code that uses createAudioFileAsync()
:
func handleSuccess(url: URL) {
print("Audio file ready at URL:", url)
}
func handleFailure(error: Error) {
print("Error generating audio file:", error)
}
createAudioFileAsync(settings: audioSettings, successHandler: handleSuccess, failureHandler: handleFailure)
...with Promisor you can let functions return a promise you can attach your handlers to instead:
If createAudioFileAsync()
were rewritten to return a promise, using it could be as simple as this:
createAudioFileAsync(settings: audioSettings).then(handleSuccess, handleFailure)
That's shorthand for:
let promise = createAudioFileAsync(settings: audioSettings)
promise.then(handleSuccess, handleFailure)
We call this an asynchronous function call. This convention has several advantages. We will explore each one.
Creating a Promise
A Promise can be created from scratch using its initializer.
let promise = Promise<String> { resolve, reject in
resolve("Yay, my first promise!")
}
You can also use the initializer to wrap a completion handler based API, like URLSession
.
let promise = Promise<Data> { resolve, reject in
session.dataTask(with: request, completionHandler: { data, response, error in
if let error = error {
reject(error)
} else if let data = data {
resolve(data)
} else {
fatalError()
}
}).resume()
}
Basically, the promise initializer takes an executor function that lets us resolve or reject a promise manually.
Guarantees
Unlike "old-style", passed-in handlers (aka callbacks), a promise comes with some guarantees:
-
Handlers added with
then()
,catch()
andfinally()
will even be called after the completion or failure of the asynchronous operation. -
Multiple handlers may be added by calling
then()
,catch()
orfinally()
several times. Each handler is executed one after another, in the order in which they were inserted.
One of the great things about using promises is chaining.
Chaining
A common need is to execute two or more asynchronous operations back to back, where each subsequent operation starts when the previous operation succeeds, with the result from the previous step. We accomplish this by creating a promise chain.
Here's the magic: the then()
function returns a new promise, different from the original:
let promise = doSomething()
let promise2 = promise.then(handleSuccess, handleFailure)
or
let promise2 = doSomething().then(handleSuccess, handleFailure)
or
let promise2 = doSomething()
.then { value
handleSuccess(value)
}
.catch { error in
handleFailure(error)
}
This second promise (promise2
) represents the completion not just of doSomething()
, but also of the handleSuccess
or handleFailure
you passed in, which can be other asynchronous functions returning a promise. When that's the case, any handlers added to promise2
get queued behind the promise returned by either handleSuccess
or handleFailure
.
Basically, each promise represents the completion of another asynchronous step in the chain.
Before, doing several asynchronous operations in a row would lead to the classic callback pyramid of doom:
doSomething(successHandler: { result in
doSomethingElse(result, successHandler: { newResult in
doThirdThing(newResult, successHandler: { finalResult in
print("Got the final result:", finalResult)
}, failureHandler: handleFailure)
}, failureHandler: handleFailure)
}, failureHandler: handleFailure)
With promises, we attach our handlers to the returned promises instead, forming a promise chain:
doSomething()
.then { result in
return doSomethingElse(result)
}
.then { newResult in
return doThirdThing(newResult)
}
.then { finalResult in
print("Got the final result:", finalResult)
}
.catch(handleFailure)
Chaining after a catch
It's possible to chain after a failure, i.e. a catch
, which is useful to accomplish new actions even after an action failed in the chain. Read the following example:
Promise<()> { resolve, reject in
print("Initial")
resolve(())
}
.then {
throw SomeError()
print("Do this")
}
.catch { _ in
print("Do that")
return doThatOnFailure()
}
.then {
print("Do this instead")
}
This will output the following text, assuming that doThatOnFailure()
completes successfully:
Initial
Do that
Do this instead
Note: The text "Do this" is not displayed because the SomeError
error caused a rejection.
Error propagation
You might recall seeing handleFailure()
three times in the pyramid of doom earlier, compared to only once at the end of the promise chain:
doSomething()
.then { result in doSomethingElse(result) }
.then { newResult in doThirdThing(newResult) }
.then { finalResult in print("Got the final result:", finalResult) }
.catch(handleFailure)
Basically, a promise chain stops if there's an exception, looking down the chain for catch
handlers instead. This is very much modeled after how synchronous code works:
do {
let result = try doSomethingSync()
let newResult = try doSomethingElseSync(result)
let finalResult = try doThirdThingSync(newResult)
print("Got the final result:", finalResult)
} catch {
handleFailure(error)
}
Promises solve a fundamental flaw with the callback pyramid of doom, by catching all errors, even thrown exceptions and programming errors. This is essential for functional composition of asynchronous operations.
Composition
Promise.resolve()
and Promise.reject()
are shortcuts to manually create an already resolved or rejected promise respectively. This can be useful at times.
Promise.all()
and Promise.race()
are two composition tools for running asynchronous operations in parallel.
We can start operations in parallel and wait for them all to finish like this:
Promise.all(books.map { fetchMetadata(for: $0) })
.then { allMetadata in
zip(books, allMetadata).forEach { book, metadata in
print("\(book.title) metadata: \(metadata)")
}
}
Installation
Promisor is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod 'Promisor'
Author
Ennio Bovyn, [email protected]
License
Promisor is available under the MIT license. See the LICENSE file for more info.