FREE TRIAL: Accelerate your app development career with Hacking with Swift+! >>

How to use continuations to convert completion handlers into async functions

Paul Hudson    @twostraws   

Older Swift code uses completion handlers for notifying us when some work has completed, and sooner or later you’re going to have to use it from an async function – either because you’re using a library someone else created, or because it’s one of your own functions but updating it to async would take a lot of work.

Swift uses continuations to solve this problem, allowing us to create a bridge between older functions with completion handlers and newer async code.

To demonstrate this problem, here’s some code that attempts to fetch some JSON from a web server, decode it into an array of Message structs, then send it back using a completion handler:

struct Message: Decodable, Identifiable {
    let id: Int
    let from: String
    let message: String
}

func fetchMessages(completion: @escaping ([Message]) -> Void) {
    let url = URL(string: "https://hws.dev/user.json")!

    URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            if let messages = try? JSONDecoder().decode([Message].self, from: data) {
                completion(messages)
            }
        } else {
            completion([])
        }
    }.resume()
}

Although the dataTask(with:) method does run our code on its own thread, this is not an async function in the sense of Swift’s async/await feature, which means it’s going to be messy to integrate into other code that does use modern async Swift.

To fix this, Swift provides us with continuations, which are special objects we pass into the completion handlers as captured values. Once the completion handler fires, we can either return the finished value, throw an error, or send back a Result that can be handled elsewhere.

In the case of fetchMessages(), we want to write a new async function that calls the original, and in its completion handler we’ll return whatever value was sent back:

func fetchMessages() async -> [Message] {
    await withCheckedContinuation { continuation in
        fetchMessages { messages in
            continuation.resume(returning: messages)
        }
    }
}

As you can see, starting a continuation is done using the withCheckedContinuation() function, which passes into itself the continuation we need to work with. It’s called a “checked” continuation because Swift checks that we’re using the continuation correctly, which means abiding by one very simple, very important rule:

Your continuation must be resumed exactly once. Not zero times, and not twice or more times – exactly once.

If you call the checked continuation twice or more, Swift will cause your program to halt – it will just crash. I realize this sounds bad, but when the alternative is to have some bizarre, unpredictable behavior, crashing doesn’t sound so bad.

On the other hand, if you fail to resume the continuation at all, Swift will print out a large warning in your debug log similar to this: “SWIFT TASK CONTINUATION MISUSE: fetchMessages() leaked its continuation!” This is because you’re leaving the task suspended, causing any resources it’s using to be held indefinitely.

In fact, in our code right now we have exactly that problem, and it wasn’t obvious because Swift can’t track usage of the completion handler. The problem is here, in the original fetchMessages() function:

if let data = data {
    if let messages = try? JSONDecoder().decode([Message].self, from: data) {
        completion(messages)
    }
} else {
    completion([])
}

That attempts to decode the JSON into a Message array and send back the result using the completion handler, or send back an empty array if nothing came back from the server. However, I slipped in a mistake: we should have requested user-messages.json from my server, but actually we requested user.json, so our decode will fail. We don’t have an else block to handle that outcome, and therefore the completion handler will never be called and our continuation will be leaked.

The correct code should really be this:

if let data = data {
    if let messages = try? JSONDecoder().decode([Message].self, from: data) {
        completion(messages)
        return
    }
}

completion([])

So, even though we’re still fetching the wrong URL, at least we’re now sure our continuation wrapper is in a good place – we know the continuation is being called once and only once. And so if you want you can replace the withCheckedContinuation() function with a call to withUnsafeContinuation(), which works exactly the same way but doesn’t add the runtime cost of checking you’ve used the continuation correctly.

I say you can do this if you want, but I’m dubious about the benefit. It’s easy to say “I know my code is safe, go for it!” but I’d be wary about moving across to unsafe code unless you had profiled your code using Instruments and were quite sure Swift’s extra checks were causing a performance problem.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for iOS devs who want to become complete senior developers — from October 18th to 24th. Learn how to apply iOS app architecture patterns through a series of lectures and practical coding sessions.

Learn more

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.