/ COMBINE

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!))
    }
}
  1. Create an authentication context.
  2. Create a future for Touch ID or Face ID authentication. We set value’s type to Void since any value represents a success.
  3. Request authentication with Touch ID or Face ID.
  4. 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 or nil 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 not miss any new content.