Search code examples
iosswiftasync-awaitconcurrencyqueue

Why doesn't @autoclosure @escaping on an async-function work and how to fix it


I have a queue for async-functions like so:

class Queue {
    var queue: [() async -> Void] = []
    
    func enqueue(_ operation: @escaping () async -> Void) {
        queue.append(operation)
    }
}

and you use it like this:

func networkCall() async {
    Task.sleep(2)
}

let queue = Queue()
queue.enque(networkCall)

I wanted a similar look for trying to enque a function with arguments like this: queue.enque(networkCall(duration: 2))

To do this I have to add the @autoclosure attribute to the enqueue(_:) function like so:

func enqueue(_ operation: @autoclosure @escaping () async -> Void) {
    // ...
}

But this resulted in an error: 'async' autoclosure parameter in a non-'async' function.

My question is, why does this error occur? The autoclosure is also escaping so it's not getting executed inside the enque function, so why does the compiler care that it's not marked with async? I also want to know if there is any fix to this so as to achieve a function call like queue.enque(networkCall(duration: 2)) for my enqueue function.

Thanks in advance.


Solution

  • While yes, there is nothing technically wrong with an escaping async autoclosure as a parameter of a non-async function, Swift still prevents you from doing that because of "stylistic" reasons.

    Think about how you would call enqueue. You cannot call it the way you'd like:

    queue.enqueue(networkCall(duration: 2))
    

    Instead, you need to do:

    queue.enqueue(await networkCall(duration: 2))
    

    Because one of the fundamental principles of Swift Concurrency is that all suspension points be marked with await, just like how every place where an error could occur is marked with try.

    Try actually making enqueue async and see it for yourself - you must write

    await queue.enqueue(await networkCall(duration: 2))
    

    So if enqueue is not async, then that means something like queue.enqueue(await networkCall(duration: 2)) could appear in non-async code. You'd end up with await seemingly appearing in non-async code. Of course, that's not true under the hood, but it still is surprising and requires readers to go to the definition of enqueue to see that it is actually an autoclosure.

    In fact, some people argue that @autoclosure should not be used with @escaping at all (without anything async), because the automatically created closure could unexpectedly capture self and creates a retain cycle.

    I'd suggest just take a non-auto closure. The syntax is just a matter of () vs {} anyway:

    queue.enqueue { await networkCall(duration: 2) }
    

    You would need the await whether or not this restriction is lifted or not.