Not surprisingly, this is something that I’ve thought about a lot, though I don’t have a ready answer. I agree that the current setup doesn’t play well with Result
propagation – basically every consumer of a closure must anticipate whether its callees will want to propagate errors. But I’m very wary of trying to “split the world” of closures.
First, I want to point out one subtle consideration. Allowing callee closures to “break out” across stack frames magnifies exception safety concerns in a big way. You may recall when we were discussing catch_panic
that one of the major concerns was that, so far, safe Rust could by and large ignore exception safety (this is not true for unsafe Rust). Exception safety essentially means “keeping in mind that a function might panic” – put another way, it means that all of your cleanup needs to go in destructors. If we add TCP-preserving closures, then if one of them chooses to return, the same considerations apply, only they apply outside of a panic scenario and just during regular execution (the possibility of this becoming commonplace is precisely why catch_panic
was controversial). So imagine I have this function:
fn foo<F>(&mut self, f: F) where F: FnOnce() {
self.counter += 1;
f();
self.counter -= 1;
}
This kind of logic is fine today, but if f()
may in fact “skip” the remainder of my function, then self.counter
will get out of date.
This leads me to conclude that a TCP-preserving closure really wants to be another kind of return value. This also means we can avoid building “longjmp” into the language. The return value might look something like this:
enum ControlFlow<T> {
// indicates that a TCP-preserving closure did not return, instead executing a break/return/etc
Nonlocal,
// indicates the TCP-preserving closure completed normally
Local(T)
}
Now the intermediate frames can propagate this naturally, just as they do a Result
:
fn foo<F>(&mut self, f: F) -> ControlFlow<T>
where F: FnOnce() -> ControllFlow<T>
{
self.counter += 1;
let r = f();
self.counter -= 1;
r
}
This seems nice, but there are a couple of (maybe) problems. First, we want the compiler to understand that when I do break
in such a closure it has special meaning. This suggests we probably want some kind of syntax to express that (seems reminiscent of coroutines to me). This special syntax would also allow the compiler to insert some code that wraps the location where the closure is used to execute the non-local control flow (e.g., returning a value etc). We actually had basically this mechanism long ago and it worked ok; I imagine we’d consider something similar.
Second, it means that these closures can easily interoperate with functions that don’t understand their special semantics. This is good and bad. It’s nice that existing closures work, but they don’t know to interpret ControlFlow
specially and avoid extra work. So you might imagine that you want different traits, which will start to split the world into “control-flow-aware” and not control-flow-aware. Sounds vaguely dystopian for me.
Finally, one major use case here (I believe) is things like iterators etc, and their signatures already exist, so we should consider if there are ways to backwards compatibly allow them to accept either regular closures or TCP-preserving closures.
So, I don’t have anything close to a solution here, but I suspect that the right one will (a) involve some return types that are understood; (b) allow some kind of syntax to have callees “opt-in” to a different interpretation of return/break and © consider how to distinguish an ordinary closure from a TCP one, and whether we can gt some kind of back-compat.