After our thorough review of closures, it is time to take a step back and reconsider our original goal for this chapter: showing effective ways to handle concurrency in Swift. We have learned that callbacks provide an architectural building block to write concurrent code, and that closures are a great way to implement callbacks. In this section, we should ask ourselves how callbacks perform in more complex, realistic scenarios.
Let's consider this scenario:
- You want to download a list of items using a REST API. Each item is described using JSON and contains a category name, a URL to retrieve an image representing that category, and an alternate URL to use in case the first one fails.
- For each item, you will want to download an image to be displayed in your UI from its preferred URL or from the alternate URL in case anything fails.
- If both the preferred and alternate URL have problems, you will want to use a default image.
Using callbacks, you could think of writing the following pseudo-code:
getListOfItems(listUrl) { list in
list.forEach { item in
getImage(url: item.url) { image in
if let image = image {
viewModel.addItem(item.categoryName, image)
} else {
getImage(url: item.alternateUrl) { image in
if let image = image {
viewModel.addItem(item.categoryName, image)
} else {
viewModel.addItem(item.categoryName,
defaultImage(category: item.categoryName))
}
}
}
}
}
Although it solves a pretty trivial problem, this code has some big issues:
- It spans over three nested async callback levels (without counting the forEach callback), which makes it obscure and hard to maintain
- It contains replicated code, which is never desirable
The preceding example shows a general property of code that uses callbacks to handle sequences of asynchronous operations when each async operation depends on the result of the previous. It is so widespread that it even has a name: callback hell.
Of course, we could rewrite our code using functions to improve its modularity, as in the following example:
func useImage(name: String, url: String, onError: () -> Void) {
getImage(url: url) { image in
if let image = image {
viewModel.addItem(name, image)
} else {
onError()
}
}
}
getListOfItems(listUrl) { list in
list.forEach { item in
useImage(item.categoryName, item.url) {
useImage(item.categoryName, item.alternateUrl) {
viewModel.addItem(item.categoryName,
defaultImage(category: item.categoryName))
}
}
}
}
As you can see, while nesting has not improved, at least we could write code that expresses more clearly our aim and with less code duplication. This is still far from being a good solution, though, and, indeed, on two accounts:
- Our code design comes to depend on the intricacies of dealing with callbacks. Specifically, you see we decided to coalesce the getImage and addItem operations within the same function and use a onError callback to useImage just for the sake of minimizing code duplication. Both design decisions are not driven by any considerations we could relate to our application business logic; rather, it is driven by implementation details of the abstractions we are using to deal with concurrency.
- We could surely try to improve our code even more. For example, we could coalesce the two useImage calls into a new function, thus reducing the callback nesting, but you may possibly see clearly that this approach does not scale with the complexity of the async workflows we would like to be able to address in our code. In addition, it exacerbates, even further, the design problem we have just mentioned, so it would not be entirely desirable, even if practical.
In the rest of this chapter, we will examine two alternative approaches to deal with concurrent operations, namely futures and promises, and reactive programming, which leverages callbacks with the aim of providing higher-level abstractions to handle concurrency.