async keyword indicates code that will run concurrently, and the
await keyword indicates a dependency on asynchronous code. If an error is triggered during an asynchronous operation, the entire chain of computations is canceled and an error is returned to the original caller.
iOS programming can be quite callback heavy, so the proposal to bring this kind of programming to Swift was very exciting. A single level of callbacks is usually no big deal, but two or more nested callbacks quickly become a mess. It can be difficult to make sure you’re handling all potential error paths, and it’s hard to do things where one callback triggers multiple concurrent processes and then waits for them. In general, it’s not easy to compose callbacks into a single pipeline. Even if you manage to pull it off, it can be difficult to catch bugs and can leave you with brittle code that’s difficult to change or test.
The manifesto’s motivating example is a classic example of callback hell:
As the proposal points out, there are a number of problems with this:
- It’s very hard to read
- The indentation pushes the code all the way to the right of the screen (the pyramid of doom)
- You could very easily miss any of those error-handling guard’s
- It’s cluttered with lots of boilerplate that doesn’t have much to do with the problem you’re solving.
Lattner’s proposal would let you do this instead:
This is obviously much nicer, but the proposal was never integrated into Swift. In fact, it never even entered the Swift evolution process. Since I first read the manifesto, I’ve been following Swift development closely, hopeful that async/await would soon be included in the language and would let me reorganize some of the messiest and hardest-to-debug parts of my applications. Eventually, I gave up and moved on.
Combine to the Rescue
Recently, I was spending some time learning Combine and I realized something that I think most Swift programmers don’t realize:
Swift already has async and await: they’re just not spelled that way, and they require iOS 13.
Apple’s new Combine framework lets you create very flexible, reliable wrappers around any callback-based API. You can then use its standard operators (like
collect, and more) to shape a pipeline of concurrent operations. Combine pipelines have error handling baked in and they let you write your code almost as if it was running sequentially.
Additionally, it’s very easy to test different scenarios when you have a Combine pipeline: instead of running your actual callback code (which might access the Internet or the device camera, for example), you can push predictable values into the pipeline and see if the rest of your code works properly.
When you wrap existing asynchronous API’s, you’ll still have some callback-based code, but it will be limited to a single level, and confined to one function that you’ll need to audit to make sure all return paths either return a value or signal an error. You can then reuse these wraps anywhere in your project. The Combine operators you’ll apply to these futures are well-tested and well-defined by Apple, so there are fewer places your code can go wrong or behave unpredictably.
Wrapping an API
A full introduction to Combine is beyond the scope of this post (but see the Further Reading section below if you want to learn more). For our purposes, the most important aspect of Combine is that it includes two operators that can help us easily encapsulate a callback-based API and give it a much, much nicer publisher-based API:
Using these tools, we can re-write Lattner’s example like so:
In order to make this work, we need to convert callback-based operations into Combine publishers. Apple’s Future publisher lets us do just that. It creates what Joseph Heck calls a “one-shot publisher” – it runs, publishes a value if there’s no error, then it completes. Each loading function now publishes a value or returns an error. The bodies of these functions would create and return Future publishers that would invoke asynchronous API’s.
flatMap accepts a value from a publisher (for example, a Future) and returns a new Publisher. We can easily chain publishers together this way – the value that comes into flatMap can be used to invoke another Future (a publisher), which then becomes part of the Combine pipeline.
Actually, Combine has even let us improve on the original: the example code in the manifesto doesn’t load the image resource until the data resource has already finished – but since there’s no dependency between the two, our Combine snippet simultaneously fetches the image data and the data profile using the built-in
Future API is based on promises, so you can easily create a future from any kind of asynchronous API. Just invoke the promise the Future creates for you with either a success value or an error when your callback runs.
The manifesto code is a simple example, but you can generalize this to any of the many asynchronous API’s offered by iOS. Additionally, in this particular case, the custom future isn’t even necessary, because
URLSession supports Combine natively:
You can generalize this technique to any asynchronous API. Just create a Future, invoke your asynchronous API and configure a callback, then be sure to call the promise with the result of the API call. You can chain multiple Future’s together using flatMap, or you can combine or transform the results using Combine’s long list of operators. A great example is the very convenient receive operator to quickly ensure that you’re running on the main thread.
I hope this helps you escape from callback hell! The only catch to keep in mind is that Combine requires iOS 13.
Photos: Arashiyama Bamboo Grove, Kyoto, Japan; Avebury Stone Circle, Avebury, England; the roof of the Silver Pavilion (Ginkaku-ji), Kyoto, Japan