`move` operator for ergonomic captures

Motivated by the recent proposal for autoclones, I want to suggest a different approach. I strongly dislike the autoclone proposals, but that's a different topic which I may post in the future. For now, I'll just focus on the specific pain point raised in the discussions: captures of Rc/Arc based shared pointers in closures and async blocks. Specifically, the following pattern is imho familiar to anyone who wrote async code:

fn do_stuff(serv: Server, foo: Arc<Foo>, bar: Arc<Bar>) {
    serv.set_handler_fn_mut({
            let foo = foo.clone();
            let bar = bar.clone();
            |req| {
                let foo = foo.clone();
                let bar = bar.clone();
                async move {
                    stuff_1(&foo, &bar);
                    handle_1(req, foo, bar);
                }
            }
        })
        .set_handler_fn_mut({
            let foo = foo.clone();
            let bar = bar.clone();
            |req| {
                let foo = foo.clone();
                let bar = bar.clone();
                async move {
                    stuff_2(&req);
                    handle_2(req, foo, bar);
                }
            }
        });
}

...yeah, that's annoying, and in real life there can be many more shared pointers plus some extra local state. The worst part is that it feels like something which should be handled automatically, like with autocaptures, and it looks so insignificant, and you need to repeat the same boilerplate so many times, just to use the resulting cloned Arc once in the handler function.

Automatically inserting those clones would be one approach, but it has knock-on effects. Rust generally tries to avoid hiding execution of arbitrary code (yes, there are exceptions, like Deref and Drop, but for the most part it's true and the exceptions are themselves very predictable).

What if we had a certain operator (let's call it .move), which would allow to move out-of-scope values with inline call syntax? The above example I'd want to write in this way:

fn do_stuff(serv: Server, foo: Arc<Foo>, bar: Arc<Bar>) {
    serv.set_handler_fn_mut(|req| async {
            let foo = foo.clone().move;
            let bar = bar.clone().move;
            stuff_1(&foo, &bar);
            handle_1(req, foo, bar);
        })
        .set_handler_fn_mut(|req| async {
            stuff_2(&req);
            handle_2(req, foo.clone().move, bar.clone().move);
        });
}

Much better! Now we don't have any unnecessary bindings. The bindings are only introduced when we would actually use them in normal straight-line code, when we want to use a value multiple times. The cloning also happens as close as possible to the actual use site. The second handler looks in the lightest way possible without obscuring any effectful operations (clones). Note that we no longer need async move: foo and bar are unconditionally cloned and force-moved into the innermost scope, while req is handled by the usual autocapture rules.

With regards to the semantics, the expressions with .move operators are supposed to desugar basically to the verbose variants written above. Actually, I haven't yet made up reasonable semantics which handle the case of nested capturing scopes, but at least a single closure or async block can be handled in this way. I.e.

Consider a capturing expression (closure definition, async block, generator etc) which contains an expression of the form expr.move, which is not nested in another capturing expression. Let us write that expression as Capture(expr.move), where Capture is the term of the entire capturing expression, and expr.move is a single use of the move operator (syntactically identical subexpressions are treated as different in the above, i.e async { (expr.move, expr.move) } performs two separate moves rather than trying to give some non-linear semantics to a single move). We have the following desugaring:

Capture(expr.move) ==> { let tmp = expr; Capture(tmp) }

with an extra requirement that tmp is captured by move unconditionally, without applying the usual autocapture analysis.

So e.g. the following expressions are definitionally equivalent, bar autocapture:

// this
it.map(|elt| transform(elt, state.frobnify().move)
// desugars to this
it.map({
    let tmp = state.frobnify();
    move |elt| transform(elt, tmp)
})

// this
tokio::spawn(async { foo((bar + baz).move).await })
// desugars to this
tokio::spawn({
    let tmp = bar + baz;
    async move { foo(tmp).await }
})

What if the .moved expression contains references to inner scope? In that case the expression doesn't typecheck, with the borrow checker error pointing out that the a local variable is used outside its scope.

What if the same !Copy value is attempted to move twice? The borrow checker complains that the value was moved, as usual. The desugaring preserves the evaluation order of moved subterms, so that should cause no issue.

What if capturing scopes are nested? I don't have a good answer at the moment. The obvious answer is that the transformation is applied only to the innermost scope, but that's not ergonomic enough for the intended async-valued closure use case. Then again, perhaps we can mostly ignore async-returning closures, since those should be solved by proper async closures, which have only one capturing scope.

What if the move operator is used outside of a capturing scope? I think it should be a syntax error. Rust is already move by default, what would an explicit move even mean? We wouldn't want to make users think that sometimes a move isn't a move without the magic keyword.

Should the operator be overloadable? Or contain a conversion trait, like IntoFuture for .await? I think no. I don't see a reason to do it.

Note that type checking is not affected in any way. In particular, if expr: &T, then expr.move moves a reference. It doesn't try to convert it into an owned value.

The advantage over explicit capture lists in the closure/async definition:

  • more lightweight syntax: only the bare minimum of effectful operations, plus .move to denote the scope move trick (that's just a simple memcopy, so not interesting from code analysis perspective).
  • Complicated moved expression don't require temporary bindings. Bindings are created as usual, when you want to reuse values.
  • The captured expression is right at its use site, so code is more readable.
  • Doesn't affect autocapture in any way, apart from the force-moved expression. Old code isn't affected by the addition of .move. I also expect many cases of explicit async move blocks and move closures to be unnecessary with this fine-grained moving. In fact, why would anyone want async move {} when you can just .move the specific offending variable?
  • Minimal changes to the parser. .move syntax doesn't seem to conflict with anything, and it's just another keyword postfix operator. By contrast, changing the syntax of closures and async {} would be much more complex, and would likely break many macros.
  • More flexible. Arbitrarily complex expressions are naturally supported. For example, I'd want to support clones parametrized by an explicit allocator, like vec.clone_in(alloc).move or Box::new_in(value, arena).move.

The advantages over autoclone:

  • Again, more flexible. Autoclone (or recently, Claim) suggestions work only for cloning, mostly of refcounted pointers. Any more complex use cases are ignored.
  • No hidden operations. The expression to be captured is as explicit as in current Rust, but all boilerplate (compiler-placating trivial bindings and scopes) is removed.
  • Importantly, doesn't have any ripple effects on the rest of the Rust code or the ecosystem. Solves precisely the annoying problem of captures, and nothing more (though perhaps it could be extended to deal with overeager scopes in other places, like scrutinees of if and match).

Bikeshed: syntax

I'm not particularly tied to the specific .move syntax. I think it's good: the precedent of postfix keyword operators is well established, the syntax looks evocative of the semantics, the move keyword is barely used in current Rust, and I can't see any parser conflicts. But I think any postfix or prefix operator syntax could work. We could even make it a new block, but that looks like overkill, and would likely be less readable.

Edit: Downsides

  • The inverted control flow may be confusing.

    // prints "cab"    
    async {
      print!("a");
      print!("b");
      print!("c").move;
    }.await 
    

    Specific cases can be linted against, but in general there is no way to prevent it. Application of .move to expressions with side effects is the intended usage, .clone() is the most important use case.

  • This can lead to some nasty bugs if arbitrary side effects are allowed:

    wopr.when_the_russians_fire_their_nukes(|| {
      // Whoops, should have been:
      // let abort_signal = our_nukes.move.fire();
      let abort_signal = our_nukes.fire().move;
      // ...
    })
    
  • The application of .move operator may happen in branches, but the semantics execute all .move invocations unconditionally in sequence before the capturing block executes.

    fn unconditional(cond: bool, foo: impl FnOnce()) -> impl FnOnce() {
        // foo is executed unconditionally before the closure
        || if cond { foo().move } else {} 
    }
    
    // Equivalent to
    fn unconditional(cond: bool, foo: impl FnOnce()) -> impl FnOnce() {
        let tmp: () = foo();
        || if cond { tmp } else {} 
    }
    
  • Weird scoping interaction with existing postfix operators:

    fn early_return(foo: Option<()>) -> Option<impl FnOnce()> {
        // The ? returns from the containing function, not the closure.
        || foo?.move
    }
    
    // Equivalent to
    fn early_return(foo: Option<()>) -> Option<impl FnOnce()> {
        let tmp: () = foo?;
        move || tmp
    }
    
    async fn nested_async(foo: impl Future<Output=()>) -> impl Future<Output=()> {
        // Actually, this future just returns the output of `foo`.
        // It doesn't await inside its body.
        async { foo.await.move }
    }
    
    // Equivalent to
    async fn nested_async(foo: impl Future<Output=()>) -> impl Future<Output=()> {
        let tmp: () = foo.await;
        async move { foo }
    }
    
  • Iterated application looks confusing:

    cx.spawn(|| async { foo().move.bar().move });
    // Arguably, `.move` should be evaluate from outside to inside, i.e.
    let t1 = foo();
    cx.spawn(move || {
        let t2 = t1.bar();
        async move { t2 }
    });
    // But perhaps they should evaluate left to right, resulting in error.
    cx.spawn(|| {
        let t1 = foo();
        let t2 = tmp.bar();
        async move { t2 }
    });
    // Or maybe it should be a compile error?
    
  • Doesn't help with nested capturing scopes, e.g. a closure which returns async {}.

Alternatives

  • Explicit lists of captures before a capturing block. However, these can get quite verbose for real-world code with long variable names and captured fields. Consider this example:

    cx.spawn(closure!(
        move saved_context, 
        move lsp_adapter_delegate,
        clone path, 
        clone languages = self.languages, 
        clone telemetry = self.telemetry, 
        clone slash_commands = self.slash_commands,
        ref workspace,
        |this, mut cx| async_block!(
            ref mut cx,
            move this, 
            move saved_context, 
            move lsp_adapter_delegate,
            clone path, 
            clone languages = self.languages, 
            clone telemetry = self.telemetry, 
            clone slash_commands = self.slash_commands,
            ref workspace,
            async move {
                /* body */
            }
        )
    ));
    

    Arguably, that's worse than the existing pattern with nested scopes and variable bindings. Some of the above annotations could be omitted if explicit captures could be combined with autocapture rules, and if capture modes of the same type could be grouped, but most of the captures would still need repeating.

  • Use a prefix operator (move foo). Arguably that would somewhat discourage nesting of such expressions, but wouldn't solve the issue with conditionals and early return, ?, .await.

  • Use block syntax (move { foo }). However, that may encourage putting more statements inside, including side-effectful ones (e.g. logging and asserts. The benefit is that it may be more obvious that there is weird interaction with scoping and control flow, in particular that the variables and early returns inside refer to enclosing scope.

  • Use parenthesis or brackets (move(foo), move[foo]).

  • Make it a macro? move!(foo) This may make it easier to ban potentially confusing operations inside (early returns, ?, .await, nested macros) and make it more intuitive that it's not just a simple expression. This also allows easier introduction of custom syntax, e.g. we no longer need to introduce a new keyword. Instead it can be an arbitrary macro name, e.g. capture! or eager!.

8 Likes

this violates the general invariant that an async block doesn't do anything unless it is polled, since the refcount would need to be incremented immediately. and since this is a general postfix operator, it could be applied to any method call, including those with global side effects.

additionally, it seems to violate the general linear execution of code:

async {
  print!("a");
  print!("b");
  print!("c").move;
}.await // does this print "cab"??
3 Likes

A future doesn't do anything unless polled. An async block cannot do stuff, it's just syntax, and that syntax may mean different things.

Yes, that's the point of introducing this operator. It hoists the subexression to the containing scope. The print example can be linted against, but in general the operator should be used where side effects are considered mostly irrelevant, at least with regards to their sequencing. That's true for memory allocation, few people track the specific allocation order. Usually only the averages matter, like total consumed memory or allocator pressure.

If the specific order is critical, .move should not be used. I don't think it's special. I would avoid relying too much on the order of function argument evaluation, even though it's defined left to right. Destructor order is well-defined, but for something like a mutex I much prefer an explicit drop at the end of expected scope, rather than relying on just drop order.

1 Like

I generally like the theme, but also don't like the out of order evaluation, it's basically reverse defer.

But fixing the out of order pretty much just looks like the containing block idiom, so... shrug?

My mental model of foo.clone().move is "we'd like to normally clone foo at this point, but that would make us borrow it, and the borrow checker would complain, so we do a minimal fix to placate it".

Note that out of order execution isn't unprecedented in Rust: two-phase borrows in methods do a similar thing. Normally foo.bar(foo.baz()) should be equivalent to Foo::bar(&mut foo, foo.baz()) (assuming a &mut self method). The argument evaluation order is left to right, so &mut foo borrows foo mutably, and no more methods may be called on foo until that borrow is dropped (which happens after the whole expression drops). Instead the compiler tries to make it work by delaying the first borrow, as if all arguments were evaluated to temporary bindings.

I personally think if we're going to do something that involves per-variable syntax, I'd prefer the explicit move lists, up-front on the closure or async block. That has the advantage of looking like it's something "outside" the body of the closure or async, matching the fact that it runs code outside the block.

That said, my own response to the autoclaim proposal would be to have the Claim trait as defined, but to have something like async claim or claim ||, which would flag that items that implement Claim should be .claim()ed. That avoids implicitly running code without any indication, which averts the negative ripple effects you mention. We could extend it to other places by having a claim pattern (e.g. match expr { Some(claim x) => ..., None => ... }).

10 Likes

In this case, it would be nice to have claim { .. } blocks too. And also claim fn f() { .. } that means fn f() { claim { .. } }

But I'm not sure explicitly annotating claims (whatever the syntax) is worth the noise. Implicitly copying a [u8; 4096] buffer is worse than implicitly incrementing a reference count, but it already happens today. (Also, decrementing a reference count is implicit already, via drop)

Maybe there should just be an allow by default lint (or warn by default even), and if people want to allow it crate wide, they should not be pestered with this syntactical noise.

No, that isn't how two-phase borrows work. The receiver is still evaluated first before the arguments are. What's deferred is the uniqueness constraint on the receiver reference. If the receiver weren't evaluated before the arguments, then the borrow wouldn't be two-phase, it would just be delayed.

Delaying the receiver evaluation when it's known to be a pure place mention would be another way to achieve the same effect, but that's distinctly not what the current implementation does.

1 Like

I hope we can fix that someday, so that you can have a size threshold above which you have to be explicit about copies.

Rather than a postfix .move, I think making it a block like move {} would make it clearer which code would be moved out of the closure body and makes it less likely to attempt to use a variable only defined inside the closure inside the move {} block.

3 Likes

This doesn't actually conflict with their explanation:

That is, it's only the borrow of the receiver that's delayed, not the evaluation of the place that is being borrowed.

But this is all a bit besides the point, as (as I understand it) two-phase borrows are an artifact of the stacked borrow model, and are not needed in a tree borrow world; implying that it's a bit odd to refer to just a borrow as an effect.

More relevantly for most devs though, citing this esoteric quirk of the current compiler that you can use for years without ever hearing of it or noticing it's effect isn't a particularly convincing precedent for out of order evaluation of arbitrary expressions.

To be really explicit: I do like the general theme, but I can't figure out how to avoid this hazard and still be useful.

1 Like

I think this functionality is needed, but I'd go for lists of variables with modifiers on the async block instead

let foo = Arc;
let bar = X;
async move(clone foo, ref bar, ..) {
  // foo is Arc, and bar is &X here 
  // ".." allowed any other variables with default behavior 
}
4 Likes

I personally like this idea. If it allowed to specify the default capture mode (e.g. move(clone ..)) then I feel like this would solve most of the issues for GUIs while also being explicit enough.

1 Like

Fair enough. Imho unwinding and drop glue are also examples of modified evaluation order. They contain some control flow which isn't directly visible in code. They execute after the defining expression rather than before, but is it that much of a difference?

Have you seen the example for this pattern in real-world code with real-world names that I linked in a recent thread? Imho it looks much less appealing in real life, where variable names are long and there are many captures. Worse, you need your capture declaration in sync with the usage in closure body, which looks quite annoying.

I vaguely recall you disliking mut annotations on bindings because the benefit is supposedly small while keeping them in sync with changing downstream usage is annoying. Do I remember correctly, or what it someone else? Explicit captures look like a similar issue, but with even fewer benefits. At least a lack of mut tells me something about the changes to the value of a variable. Explicit captures don't serve any other need than making the compiler accept our code. If we could magically keep references to the stack of the returning function (e.g. by using a global GC), we wouldn't need those annotations.

I also think that preceding capture annotations are harder to keep in sync with the closure's body than local .moves. The annotation itself doesn't tell you anything about the ways the binding is actually used, and getting it from the body of a potentially large closure can be difficult. On the other hand, if I see foo(bar.clone().move), then I immediately know that this is the only place where the value of bar is cloned and used (or at least any other place can be considered independently, and likely uses bar in a different way). If subsequent code changes in a way which makes the clone above the last usage, I can just remove the clone call and leave bar.move. It's an easy change with easy consequences. If I had a clone bar capture annotation, first I'd have to check that bar is really used only once in the body (ok, this can be a lint). Then I do what? Remove clone and be left with bar? Specify move bar? Remove explicit annotation and use autocapture? If we use syntax like proposed by @newpavlov in that thread, with different groups for different types of captures, I also need to move bar from clone group to the move group. That's relatively a lot of work, and a single change to code may make you redo it.

Syntax bikeshedding:

I would like to propose the following syntax:

fn do_stuff(serv: Server, foo: Arc<Foo>, bar: Arc<Bar>) {
    move foo {
        foo.clone()
    }
    move bar {
        bar.clone()
    }
    serv.set_handler_fn_mut(|req| async {
        let foo = foo.move;
        let bar = bar.move;
        stuff_1(&foo, &bar);
        handle_1(req, foo, bar);
    })
    .set_handler_fn_mut(|req| async {
        stuff_2(&req);
        handle_2(req, foo.move, bar.move);
    });
}

This is basically two new syntaxes:

  1. A move declaration. That's the move foo { foo.clone() } bit (and the similar one for bar). This basically determines how a variable will be used.
  2. A move usage (foo.move and bar.move)

The declaration defines how a value is moved to a nested closure (or future). The usage causes that operation to be invoked for every layer of closure/future up to the declaration.

Advantages of this form:

  • The declaration can be repeated for each layer, because it defines both its input and output.
  • We respect the rule about closures/futures not doing stuff unless called/polled (or at least - we violate it much less) because we can argue that the declaration is involved in causing the cloning (even if having just a declaration without usage will not cause a clone) and the declaration is outside of the closure/future.

With .move it's weird that there are expressions inside of the block, where they could even appear inside conditions and loops, but which are running once unconditionally outside of the block. This is semantically required, but creates a very unusual exception to lexical scoping. I think that's unprecedented in Rust, and I don't recall any other language having something like this, maybe with exception of macro quoting in Lisp.

And if you avoid/forbid the tricky case of arbitrary nested expressions being hoisted to the outer scope, and use variable assignments:

   let foo = foo.clone().move;
   let bar = bar.clone().move;

then this form isn't particularly compact, and repeats the variable names (which could be long!), and has the same problem of keeping this list in sync with what is used in the block.

Capture lists keep the order of execution in sync with the order of things in the source code. There could be a different syntax for longer lists.

3 Likes

This is also how const { … } behaves— The body is pre-calculated only once¹ and the cached result is used whenever the const expression is evaluated no matter how many times that occurs, whether that’s zero, one, or many times. The move { … } syntax proposed by @bjorn3 feels like a direct parallel that’s evaluated at closure creation time instead of compile time.

¹ In the case of const, during the compilation process.

1 Like

I recall that this behaviour of const {} was also contentious during stabilization. But const {} dooesn't have side effects in the strongest possible sense, and is entirely evaluated at compile time, so it's quite different from .move.

I'm generally fine with move {}, though I don't think it matters sufficiently from the inverted control flow perspective. One downside is that the block syntax encourages to put inside multiple statements. If we forbid it, it will be quite inconsistent with other blocks, and if we allow that, the issues of weird control flow are amplified. I believe people would be less likely to put complex side-effectful code, like prints and asserts, inside a relatively simple expression than inside a block. Sure, one can always use a block expression to do it, but it requires actually coming up with that possibility rather than just going along with the syntax.

Perhaps move ( ) or just prefix move operator (i.e. move foo.bar) would be better? But the first form has no precedent in the language (what is this, a magic function, like std::move?), while the second one likely isn't different enough.

2 Likes

Yeah, if cond { foo().move } else { bar } is probably confusing. In the same vein, ? and .await operators also look strange.

// The ? returns from the containing function, not the closure.
|| foo()?.move 
// Actually, this future just returns the output of `foo`.
// It doesn't await inside its body.
async { foo().await.move } 

Well... still better than autoclone :man_shrugging:

3 Likes

I can think of a few syntax categories at a use-sites (not talking about Claim trait impls or such)

  1. nothing, all-implicit
  2. single marker on the closure for automatic cloning of any capture where it's necessary
  3. explicitly declare each captured name that should be cloned on the closure
  4. some marker before the closure (on the let? the last use?) to opt in to auto-cloning

The markers could be sigils, keywords or contextual.