`move` operator for ergonomic captures

Both closures and const {} follow normal lexical scoping rules, and the identifiers in them mean what they usually mean in that scope.

I don’t see why move {} couldn’t obey them as well, in the same way that const {} does— By rejecting access to identifiers that are in-scope but won’t yet exist at the time the block is actually run (including shadowing), dropping any variables/temporaries defined in but not returned from the block at the end of its execution, etc.

2 Likes

The difference with const {} is that it's definitionally side effect-free evaluation, eg removing the block wouldn't change anything other than performance (in a non-const context where that's legal)

I'm concerned about code like:

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;
  // ...
})
1 Like

This isn’t strictly true. Of course, any side effects of const code will necessarily occur when that code is evaluated, ie at compile-time. But const code can have meaningful side effects— A panic inside a const block, for example, can prevent code that appears above it from running (by preventing the entire program from compiling). If you take the const away, you have a program that will panic at runtime, possibly after doing something nasty that the panic was supposed to prevent.

That’s quite a reasonable concern, which is why I strongly favor the block version. It provides syntactic delineation between the code that is pre-evaluated and not:

wopr.when_the_russians_fire_their_nukes(|| {
  // Whoops, should have been:
  // let abort_signal = move { our_nukes }.fire();
  let abort_signal = move { our_nukes.fire() };
  // ...
})

It’s not perfect, but I believe that this provides a reasonable tradeoff between usability and accident prevention; I can also easily understand why others might disagree.


My main point is that it’s wrong to consider this proposal as completely without precedent— This is a more a question of degree than kind, of whether we’re willing to go farther down the path that const {} started.

One issue with move { } is that people may ask "what's the difference between move {} and async move {}". For that reason I'd prefer move foo, move(foo) or move[foo].

2 Likes

Actually, I’m not sure if move is the right keyword here. Something like cached, eager, or capture might better convey the idea that the code is evaluated sooner than expected.

How would this handle cases where you want to use you clone something once but use it more than once?

For example:

foo(|| {
    bar(&a.move, &b.move, &c.move, &d.move);
    baz(&a.move, &b.move, &c.move, &d.move);
});

Would this clone and capture each variable twice?

It would (actually, this specific version doesn't clone because it doesn't contain clone, it would just try to move & capture each variable several times; that would be invalid for !Copy variables), but that would arguably just be a bad way to write it, not that different from excessive clones in normal code. I agree that the short syntax may make the bad version more appealing than it should be.

Ah right I forgot you need .clone().move to actually clone it.

That said, I guess the way to only clone once would be something like this:

foo(|| {
    let a = a.clone().move;
    let b = b.clone().move;
    let c = c.clone().move;
    let d = d.clone().move;
    bar(&a, &b, &c, &d);
    baz(&a, &b, &c, &d);
});

But then this doesn't seem really that much different than cloning outside the closure, it just avoids a level of nesting (if you do the clone in a block to avoid shadowing a, b, c and d) or the need to come up with different identifiers (if you do the clone before the call to foo).

2 Likes

Yes, it avoids extra nesting, extra rightward drift, and weird-looking shadowing bindings, at least in some cases.

The primary motivation are captures which are used only once, particularly in short blocks. In those cases the overhead of the current ceremony can be quite significant.

It also helps to move only a few specific values, while async move causes you to move everything, requiring explicit rebinding of all captured references. This effect can also be achieved with capture lists.


Another possible syntax could be macro-based, e.g. move!(foo). This makes it easy to use an arbitrary name, e.g. capture!(foo), without modifying the parser. This also makes it easier to allow only certain syntactic forms of captures, e.g. naked variables, field accesses and calls to .clone(). Importantly, it makes it easier to ban early return operators like ?, .await, or nested macro calls which would further obfuscate an already complex feature.

3 Likes

I don't think these are an example of "modified evaluation order", actually. In no way do they cause two lines of code in the same function to evaluate out of line number order.

Unwinding is a true nonlocal jump. It will never cause statements to be run in a different order, it can only cause some statements to not be executed. Similarly, drop glue doesn't reorder statements, it only adds statements.

I think that using some kind of capture block could help to make the order of evaluation more intuitive. Here in another take, similar to the one that @kornel proposed above.

move block would contain a list of either:

  • binding, in which case they are captured by move
  • binding = expression, in which case the expression is run immediately, and its result is exposed with binding inside the closure. This is equivalent to do let binding = expression(); || move(binding) { ... })
foo(|| move(a = a.clone(), b = b.clone(), c = c.clone(), d = d.clone()) {
    bar(&a, &b, &c, &d);
    baz(&a, &b, &c, &d);
});
wopr.when_the_russians_fire_their_nukes(|| move(our_nukes) {
  let abort_signal = our_nukes.fire();
  // ...
})
fn do_stuff(serv: Server, foo: Arc<Foo>, bar: Arc<Bar>) {
    serv.set_handler_fn_mut(|req| async move(foo = foo.clone(), bar = bar.clone()) {
            stuff_1(&foo, &bar);
            handle_1(req, foo, bar);
        })
        .set_handler_fn_mut(|req| async move(foo = foo.clone(), bar = bar.clone()) {
            stuff_2(&req);
            handle_2(req, foo, bar);
        });
}

If I’m not mistaken, I just re-invented the capture rules for declaring expressions inside [] from C++ lambda.

1 Like

Almost, C++ also allows to specify a default binding mode. That would be really convenient for specifying that by default all captures should be cloned, as it would be both explicit and scale well to the number of captured variables.

4 Likes

But that would be inefficient, and not what we'd want to have in Rust. In C++, clone everything is a reasonable, if inefficient, solution to the possibility of dangling pointers. In Rust, we'd want to properly capture by reference or move if possible, using a clone only if the borrow checker would complain otherwise. But that means that capturing clones become even more implicit (it's not enough to notice that a value is used in closure, you need to run the borrow checker in your head, which is non-local), it messes up the levels of semantic analysis (local syntactic definition vs a type-checking pass which is supposed to not affect semantics and can even be ignored, e.g. by mrustc), and it would also not be useful for capturing methods other than Clone::clone calls. Unlike C++, Rust doesn't have cloning as part of the core language semantics.

1 Like

Does anyone have a few good reference projects which have an issue with closure captures which could be solved by any of these proposals (autoclone, capture lists, operators etc)? I have tried searching, but couldn't find any good examples. For example, I expected an async- and gui-heavy project like Zed editor to give plenty of cases which could be solved by those proposals, so that it would be possible to evaluate their real-world efficiency and ergonomics. But, surprisingly, there were very few captures where refcounted pointers were cloned in any significant amount, so all of the proposed "fixes" to capture rules would mostly change nothing ergonomics-wise.

Inefficient compared to what? Surely manually writing let foo_clone = foo.clone(); let bar_clone = bar.clone(); and so on won't be more efficient than that.

Note that I'm not proposing to make cloning the global default, but just to give the user the ability to set this default for a specific closure. It would be explicit in the captures list of the closure, so you know what you have to look for when checking that closure.

In Rust the situation is not that much different the moment you have dynamic lifetimes (e.g. with callbacks, threads/async tasks, etc etc). Sometimes you just want to .clone() everything to simplify the lifetime management.

GTK has a clone! macro for this, though it has additional semantics for capturing strong vs weak handles.

The recent Dioxus Labs + “High-level Rust” also has a couple of examples.

2 Likes

How is having an option to specify that a given closure captures it's environment via Clone::clone "inefficient"? It isn't the most efficient thing to do in many cases, but it would be extremely handy when everything your closure captures is behind an (A)Rc. I would imagine it would be possible to override the chosen default capture method for individual values, ie:

move(clone, foo: ref, zee: move) || {
    client.insert(foo.frob(bar));
}
2 Likes

I'd say this is already a good counterexample to any simple "just clone it" proposals. Some of those invocations want a strong clone, some a weak reference, some call .to_owned() or upgrade a weak reference.

EDIT: Yeah, I read that Dioxus post. It's nowhere concrete enough to evaluate specific proposals, though. I mean actual real-world production code, with real-world identifiers, types and complexity, not another foobar example. And quotes like this

While working at Cloudflare, I had to work with a struct with nearly 30 fields of Arced data. Spawning tokio tasks looked like: (* list of a dozen clones of individual fields *)

really beg more questions than they answer. Why did they choose to clone a dozen fields out of 30 Arcs in a structure instead of cloning the whole structure? Why didn't they factor it into individual parts?

3 Likes

As with many of these situations, people generally don't write code that sucks if they can help it, so a realistic example would likely have to be a port from something making a different trade-off, like (iirc) leptos using a single leaked allocation to avoid cloning into callbacks.

Certainly it's the case that a lot of effort in the Rust UI space has specifically been about how to avoid cloning into callbacks, evaluation of something like this would be more on the side of how much easier is the UI library to write without needing to care about that (and does that actually improve the code)

You might have more luck with code dealing with tower, I remember needing to deal with lots of fiddly layers of cloning callbacks there.

2 Likes

What stops us from just adopting the C++ solution with [captures] |arg| expr syntax?

On the callback clone issue, what's more readable?

let data: Arc<Mutex<State>> = ...;

spawn({
   let data = Arc::clone(&data);
   let ctx = &ctx;
   move |_| {data.do_stuff(ctx)}
}) 

//or

spawn([data,&ctx] |_| ...)

The only problem i see is with clone, bc it indeed will make clonning a core semantic.

But this issue is really handled by recent auto claim proposal, which if accepted can be used as the meaning for data binding [data] in example. In that case a move can be enforced with [move data] syntax for example;

precise captures are required when doing nested callbacks for example. We do them in UI a lot (see some lazy list stuff).

3 Likes