Posted on June 19, 2021 by ire

Apple and the Swift team finally announced a much awaited (hehe) feature. Native asynchronous functions. And there was much rejoicing. I don’t not mean to detract for the incredible work that’s put behind Swift and its surrounding frameworks, in spite of that, I’m usually frustrated when it comes to actually using it. Some of it is down to my own prefereces and programming style, and other styles are also valid, but a few of them are caused by what I’ll describe as half-measures.

Swift has grown substantially since its introduction, and undoublty for the better. My problem is that a lot of that growth has been in too many directions at once, introducing a myriad of options and new features that only take you so far, but not farther. I need only mention dealing with Protocols with Associated Types, and experienced developers will instantly shiver. Another issue comes from the mothership itself. As exemplified by Combine, SwiftUI, and now AsyncSequence related types, things ship with a very real feel of unfinished products.

Maybe it’s rose-tinted glasses, but the impression I’ve had lately is of being less of a developer/user and more of a poorly valued beta-tester, trying to help iron out the issues of a closed source, opaque, and frankly, developer hostile ecosystem.

Despite all that, this will not be rant. Instead, let’s go down a neat rabbit whole!

The basic idea

Consider a small programme that given a collection of images and a size gives us a list of thumbnails bound by that size. Simple enough, and yet due to its asynchronous nature, quite easy to implement incorrectly. Now with async/await support, we could implement it as such:

func processImages(_ images: [UIImage], size: CGSize) async -> [UIImage] {
    await images.asyncCompactMap {
        await $0.byPreparingThumbnail(ofSize: size)
    }
}

Except we can’t. This doesn’t compile. There’s no async map, or async compact map, or any of those, unless you’re working with an AsyncSequence, which you have to build yourself. Beware: here lies boilerplate!

Instead, we’re given a few protocols for which we have to add our own concrete implementations. That sounds cumbersome, and repetitive. So let’s generalise it. Luckily, Swift has enough generic programming to help us there!

struct GenericAsyncSequence<Element>: AsyncSequence {
    typealias AsyncIterator = GenericAsyncIterator<Element>

    private let elements: [Element]

    struct GenericAsyncIterator<Element>: AsyncIteratorProtocol {
        private var elements: [Element]

        init(_ elements: [Element]) {
            self.elements = elements
        }

        mutating func next() async throws -> Element? {
            // Can't do popFirst() without further type constraining.
            if !self.elements.isEmpty {
                return self.elements.removeFirst()
            } else {
                return nil
            }
        }
    }

    init(_ elements: [Element]) {
        self.elements = elements
    }

    func makeAsyncIterator() -> AsyncIterator {
        GenericAsyncIterator(self.elements)
    }
}

Already we can see it’s non-trivial, easy to make a mistake, and worse, really hard to correctly infer performance trade-offs. The very thing we wanted to avoid by moving from completion blocks or delegation. Why something like it, but implemented by people closer to the metal, aware of how the compiler might optimise code, is not made available, I do not know. No bother. With this neat code at hand, we can go to the next step!

extension Array {
    func asyncCompactMap<ElementOfResult>(
        _ transform: (Element) async throws -> ElementOfResult?) 
        async rethrows -> [ElementOfResult] 
    {
        var elements: [ElementOfResult] = []

        for try await element in GenericAsyncSequence(self) {
            if let result = try await transform(element) {
                elements.append(result)
            }
        }

        return elements
    }
}

Let’s tacitly ignore some of the issues with the inner code, and focus on what I’d consider the two main issues we’ve uncovered.

  1. This implementation only covers arrays.
  2. This implementation only covers compact map.

If we want to have other combinators, support other collections, that’s a lot of work. And more code, more bugs. Can we improve on this? Indeed we can.

extension Array {
    func asyncCompactMap<ElementOfResult>(
        _ transform: @escaping (Element) async throws -> ElementOfResult?) 
        async rethrows -> AsyncThrowingCompactMapSequence<GenericAsyncSequence<Element>, ElementOfResult> 
    {
        GenericAsyncSequence(self).compactMap(transform)
    }
}

The new AsyncSequence family exposes all the combinators we want. As long as we manually create our sequences. So now it becomes a question of mapping things 1-1, but now our API does something it didn’t before. It returns something that also throws. The new paradigm seems to be to throw exceptions everywhere. Good-bye Result<Failure, Success>. That makes every call site responsible for handling this. Not very ergonomic. As far as I could see, once you’re returning a AsyncSomethingSequence, you can’t get out of it. So we’re back to our initial implementation, for now. We can keep the ergonomics of writing processImages(images:size) in terms of compactMap.

Problematic, perhaps, but ergonomic at the point of use, which is my preference when designing APIs and frameworks. Now let’s dig a bit deeper! This is a rabbit hole, after all. Let’s consider for a moment John A. De Goes’s proposition that descriptive variable names are a code-smell, which is a very incendiary way of saying code that could be more polymorphic should be more polymorphic. Let’s give it a go.

func batchProcess<Element, ElementResult>
    (_ col: [Element], _ continuation: (Element) async -> ElementResult?)
    async -> [ElementResult]
{
    await col.asyncCompactMap(continuation)
}


func prepareThumbnail(size: CGSize) 
    async -> ((_ image: UIImage) async -> UIImage?) 
{
    { image in
        await image.byPreparingThumbnail(ofSize: size)
    }
}

func processImages(_ images: [UIImage], size: CGSize) async -> [UIImage] {
    await batchProcess(images, prepareThumbnail(size: size))
}

Let’s go over the code. At first, we had a monomorphic implementation, tied in to an array of UIImages, as well as a built-in effect, converting images to thumbnails. Now, we’ve broken it down into smaller functions. Swift doesn’t give us partial application, so to have a partially appliable function, we need to explicity make our functions curry 🍛. So we define a curried version of UIImage.byPreparingThumbnail(ofSize:), and we define our batchProcess using continuation passing style.

Now we’ve completely decoupled our actual business logic (processing images), and the data structure (a collection of images) from the iterator, and as a result, we’ve ended up with a very general function that can work over any collection and apply transformations asynchronously.

Edit (2021-06-20)

It has been brought to my attention that the current implemnentation of next() is accidentally quadratic. Turns out that removeFirst() on arrays does not slice it, but instead, creates a new array, compacting its storage. Whilst this is a very valid optimisation, I’m of the opinion that it’s the wrong one, at least for a given size of arrays.

I’m not the first bitten by this, and probably won’t be the last. Incidentally, this issue helps making my point for me: This not being a provided construct, and relying on individual developers to implement their own versions is quite error prone, and can lead to unintended issues, as the one above. If such facilities were provideded with the language, as many others are, you’d expect it to have a lot more guarantees of reliability and performance, not to mention the issues of boilerplate.

In any case, as an added caveat:

Please do not just reuse this code in production, it’s meant as an exercise and commentary on API design.