Escaping Consuming Functions

I've been playing around with:

swiftSettings: [
   .enableExperimentalFeature("NoncopyableGenerics")
]

on the Jan 30 nightly build and I'm confused about the meaning of marking an argument of function type as being both @escaping and consuming.

Consider the following code:

public struct Continuation<T: ~Copyable, R>: ~Copyable {
    public let run: (consuming @escaping (consuming T) async -> R) async -> R

    public init(_ run: @escaping (consuming @escaping (consuming T) async -> R) async -> R) {
        self.run = run
    }

    init(_ t: consuming T) {
        run = { (callback: consuming @escaping (consuming T) async -> R) async -> R in await callback(t) }
    }
}

The second init fails to compile with:

Noncopyable 't' cannot be consumed when captured by an escaping closure

This would seem to preclude passing functions that consume ~Copyable arguments as closures full stop. Am I reading that right?

The only way I see around that is to treat the callback argument as an actual non-copyable type and say that consuming it entails invoking it exactly once. I'd be curious as to the thoughts of those actually working on this stuff.

You can explicitly copy the value to allow it to be captured:

   init(_ t: consuming T) {
        run = {[t = copy t] (callback: consuming @escaping (consuming T) async -> R) async -> R in await callback(t) }
    }
2 Likes

For comparison, this does come up sometimes in Rust, and the workaround is to put the non-copyable value in an Option(al), so you can assert at run time that it’s only consumed once in practice. (Rust does have closures that can only be called once, but they don’t always fit into all API requirements, so enforcing this bit at run time isn’t unheard of.)

1 Like

To be clear, consuming here has no effect on the type of the value, which is still just a regular escaping, copyable, multiply-invocable closure value. It's only a parameter modifier that causes the closure value to be passed callee-release while inhibiting implicit copies (but still allowing explicit copies) in the callee.

2 Likes

Interestingly that code run on the Jan 30 nightly build with Noncopyable generics enabled and T marked as ~Copyable in the type's signature yields:

'copy' cannot be applied to noncopyable types

I assume that is a bug?

Certainly understand that, but FWIW (admittedly not a lot), I keep running into situations where I'd really like the behavior that @jrose mentions above in Rust where arguments of function type which are marked consuming would require exactly one invocation.

Most notably, having the compiler enforce SE-300's frequently reiterated requirements:

As long as the entirety of the process follows the requirement that the continuation be resumed exactly once, there are no other restrictions on where the continuation can be resumed.

by treating noncopyable function types in a consistent manner with noncopyable structs and enums, coupled with, say, a version of AsyncStream.yield that was consuming, would save me from creating a lot of leaked tasks that I keep finding myself inadvertently writing.

It would also enable many of the things mentioned in Unavailable deinit in ~Copyable types(Unavailable `deinit` in `~Copyable` types - #2 by rvsrvs)

No idea if this is even doable, but it would be handy.

If T: ~Copyable, then it is correct that it can't even be explicitly copied. Using the capture list without the copy should still move ownership of the value from the enclosing scope into the closure:

run = {[t = consume t] (callback: consuming @escaping (consuming T) async -> R) async -> R in await callback(t) }

However, because the closure itself can still be escaped, retain/released, and run multiple times, t still cannot be consumed by the body of the closure.

I agree one-shot functions would be useful, but they would have to be a new kind of function type. In Rust terms, our nonescaping closures are like Rust's Fn trait, borrowing their enclosing context, and our escaping closures are akin to Arc<Cell<dyn FnMut>>, but we don't have FnOnce yet.

4 Likes