Asynchronous Programming with Futures and Promises in Swift with Combine Framework
Modern iOS and macOS applications perform lots of CPU-intensive tasks. They talk to web services, carry out complex computations and pass around high-resolution media files. These operations must be asynchronous so that the app remains responsive to users. The usual approach to asynchronous programming in Swift is callback-based. However, the following caveats apply [1]:
- Async programming with callbacks is hard to manage.
- It violates the inversion of control.
Futures and promises have none of these drawbacks. In this article we’ll cover:
- What are futures and promises?
- Why futures and promises are the direction of the asynchronous programming in Swift?
- How to use futures and promises with the Swift Combine framework?
- How to migrate from callbacks to futures?
If you are new to Combine, Getting Started with Combine will get you up to speed.
Understanding Futures and Promises
Future is a context for a value that might not yet exist. Generally, we use a future to represent the eventual completion or failure of an asynchronous operation.
Swift comes with a native implementation of futures as a part of the Combine framework:
let future = Future<Int, Never>.init(...)
Future
has two types that represent the type of value it wraps and the type of error it can fail with. In our example, these are Int
and Never
correspondingly.
In Combine’s realm, Future
is a publisher. Future
obeys all publisher’s laws and supports all operations with publishers.
Promise is the eventual result of a future.
We must initialize a future with a promise. Promises are also built into the Combine framework:
let future = Future<Int, Never> { promise in ... }
Promise
is essentially a closure that accepts a single Result
parameter. This is why Future
has a callback-based initializer.
We say that we fulfill a future when we pass a value to its promise:
let future = Future<Int, Never> { promise in
promise(.success(1))
}
We say that we reject a future when we pass an error to its promise:
struct DummyError: Error {}
let future = Future<Int, Error> { promise in
promise(.failure(DummyError()))
}
Now that we understand what is a future and a promise, let’s see how we can use them.
Basic Usage
To get a value out of a future, we must subscribe to it:
let future = Future<Int, Never> { promise in
promise(.success(1))
}
future.sink(receiveCompletion: { print($0) },
receiveValue: { print($0) })
The future is fulfilled with a value 1
:
1
finished
We can fulfill a future later by adding some delay:
let future = Future<Int, Never> { promise in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
promise(.success(1))
}
}
future.sink(receiveCompletion: { print($0) },
receiveValue: { print($0) })
print("end")
It will print:
end
1
finished
Future
is one-shot, meaning that it finishes immediately after we pass a value or an error to its promise:
let future = Future<Int, Never> { promise in
promise(.success(1))
promise(.success(2))
}
future.sink(receiveCompletion: { print($0) },
receiveValue: { print($0) })
It will print:
1
finished
Migrating from Callbacks to Combine Futures and Promises
Let’s see how we can wrap a callback-based Touch ID or Face ID authentication into a future:
// 1.
let context = LAContext()
// 2.
let authenticate = Future<Void, Error> { promise in
// 3.
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "") { isSuccess, error in
// 4.
isSuccess ? promise(.success(())) : promise(.failure(error!))
}
}
- Create an authentication context.
- Create a future for Touch ID or Face ID authentication. We set value’s type to
Void
since any value represents a success. - Request authentication with Touch ID or Face ID.
- Fulfill or reject the future.
Here is how we can get the authentication result:
authenticate
.receive(on: DispatchQueue.main) // Move to the main thread
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error): ()
case .finished: ()
}
}, receiveValue: { _ in })
Composing Futures
Futures and promises provide first-class language support for asynchronous computations. This results in several profound consequences, which are very difficult to achieve with callbacks:
- Async computations are trivial to build, compose, cancel, run in sequence and in parallel.
- Async results are trivial to transform and combine.
Here is a typical example of running several network requests in sequence:
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
deal(with: error)
} else {
URLSession.shared.dataTask(with: anotherURL) { data, response, error in
if let error = error {
deal(with: error)
} else {
URLSession.shared.dataTask(with: oneMoreURL) { data, response, error in
// do work
}
}
}
}
}
The callback-based approach scales poorly. With only three requests, we end up having lots of branches and five levels of nesting. The shape of such code is notoriously known as a callback hell.
Let’s rewrite our example with DataTaskPublisher
, which is a special flavor of a future:
URLSession.shared.dataTaskPublisher(for: url)
.flatMap { data, response in
URLSession.shared.dataTaskPublisher(for: anotherURL)
}
.flatMap { data, response in
URLSession.shared.dataTaskPublisher(for: oneMoreURL)
}
.sink(receiveCompletion: { ... },
receiveValue: { ... })
The new code has a clear improvement in readability.
On the second benefit, we never deal with nil
values explicitly. Conversely, in the previous example, we get triple optionals in the callback.
Futures eliminate the entire class of errors related to having
nil
values ornil
errors.
Let’s say we’re executing requests in parallel. With callbacks, we would have to resort to higher-level APIs, like Grand Central Dispatch or OperationQueue
, and it would still be a non-trivial task. However, it is straightforward to achieve with futures:
let combined = Publishers.Zip3(
URLSession.shared.dataTaskPublisher(for: url),
URLSession.shared.dataTaskPublisher(for: anotherURL),
URLSession.shared.dataTaskPublisher(for: oneMoreURL)
)
combined.sink(receiveCompletion: { ... },
receiveValue: { (value1, value2, value3) in ... })
Moreover, not only can we put futures in systematic ways, but we can transform their results, without removing them from the future context. Here I highlight commonly used transform operators in Combine.
What if a future is rejected? In this case, Combine provides plenty error handling options at our disposal.
Debugging futures and promises is different from the traditional methods, like manually setting breakpoints and examining stack traces. I explain four troubleshooting techniques in Debugging with Swift Combine Framework.
Summary
We use a future to represent an asynchronous computation and we use a promise to deliver a value (or an error) to the future.
Futures and promises lend themselves to delivering a clean and scalable implementation of a compositional asynchronous programming model.
The Swift Combine framework provides native implementation of futures and promises along with a rich set of operations.
For all these reasons, I endorse futures and promises as the direction of the asynchronous programming in Swift.
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.