Delayed closure capture borrows

Closures are in ways a control flow device, but because they desugar to regular objects, they cannot do things the other control flow primitives can. Both branches of an if-else can mutate the same data, but you can not do the same with a function that takes closures for the branches, because each closure holds references to its captures for its entire lifetime.

So, it would be useful to have a way to create closures that do not hold borrows. Specifically, closures that

  • Cannot be given references to their mutable captures as input.
  • Do not let any references to their mutable captures escape (be returned/some other way if it exists).

Then any number of these can safely exist and have captured the same data, since it isn't possible for their borrows, which only exist during calls, to overlap. (As long as they aren't Send) This lets functions like the if-else example above work.

Every use of this kind of closure would be checked to ensure that its captures can be legally borrowed at that point, where a use is either a direct call or passing the closure to a function.

Async blocks are also kind of control flow devices, but I'm not sure if something similar could be applied directly to them. These closures could be captured by async blocks, though, and allow cell-free concurrent mutation:

let mut state = State::new();
let mut f = || {...}; // mutate state
let mut g = || {...}; // mutate state
join(
  async {...}, // use f
  async {...} // use g
).await;

Questions

Are there safety holes I'm missing?

The borrow checker will prevent illegally passing regular references to sealed closures, but how do you detect passing other sealed closures cleanly? Checking how the lifetimes of the arguments match the other closures it might alias with would catch a lot, but I'm not sure how you could improve on that.

Can this behavior be inferred, or should it use a modifier like move, and leave regular closures unchanged?

2 Likes

Deferred capture in the vein of two-phase borrows may be plausible. But using more permissive "only once called" capture gets highly problematic quickly.

Just passing the captures at the call site does work. But if you want to be able to use delayed capture closures in any case where you wouldn't be able to write the desugar to more arguments in a trivial (if annoying) manner, there are fundamental issues.

Moving impl FnMut requires that all of its mut captures are unique, not only when calling it. This is a “fun” footgun for unsafe code known to wg-ucg. If that changes, the possibility for this feature may also change as a result.

1 Like

Maybe I'm misunderstanding, but I was picturing these closures not having reference fields to not violate reference specific rules like those, and instead holding raw pointers, that get converted to references during the call. Does that still cause issues?

other issues with delayed borrowing: you can still have a closure running multiple times even without other threads: recursive calls -- either directly, or indirectly, or even via unix signal handlers

Sometimes, you can use macro_rules! to avoid repetitive code in these cases. A macro_rules! macro can access local variables if those variables are in scope when the macro is declared (i.e. the macro_rules! should be inside the function).

1 Like