Skip to content

RFC for convenient, explicit closure capture using move($expr) expressions#3968

Open
nikomatsakis wants to merge 3 commits into
rust-lang:masterfrom
nikomatsakis:move-expressions
Open

RFC for convenient, explicit closure capture using move($expr) expressions#3968
nikomatsakis wants to merge 3 commits into
rust-lang:masterfrom
nikomatsakis:move-expressions

Conversation

@nikomatsakis

@nikomatsakis nikomatsakis commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

View all comments

Add move($expr) syntax inside closures, async blocks, and generators. A move($expr) evaluates the expression at closure-creation time and captures the result by value. This gives precise control over what a closure captures and when, without needing temporary variables outside the closure.

A prototype implementation is available thanks to @TaKO8Ki

Desired feedback:

Important

Since RFCs involve many conversations at once that can be difficult to follow, please use review comment threads on the text changes instead of direct comments on the RFC.

If you don't have a particular section of the RFC to comment on, you can click on the "Comment on this file" button on the top-right corner of the diff, to the right of the "Viewed" checkbox. This will create a separate thread even if others have commented on the file too.

Rendered

Add `move($expr)` syntax inside closures, async blocks, and generators. A `move($expr)` evaluates the expression at closure-creation time and captures the result by value. This gives precise control over what a closure captures and when, without needing temporary variables outside the closure.
@nikomatsakis nikomatsakis added the T-lang Relevant to the language team, which will review and decide on the RFC. label Jun 4, 2026
@nikomatsakis

Copy link
Copy Markdown
Contributor Author

This has been the subject of much prior discussion. I'm nominating for brief lang-team discussion in our upcoming Wednesday meeting.

@rustbot label +I-lang-nominated

@rustbot rustbot added the I-lang-nominated Indicates that an issue has been nominated for prioritizing at the next lang team meeting. label Jun 4, 2026
Comment thread text/0000-move-expressions.md
Comment thread text/0000-move-expressions.md Outdated
Comment thread text/0000-move-expressions.md Outdated
Comment thread text/0000-move-expressions.md Outdated
|| {
let vec = move(input.vec);
let data = move(&cx.data);
let output_tx = move(output_tx);

@kennytm kennytm Jun 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let output_tx = move(output_tx);
let mut output_tx = move(output_tx);

This is used in line 96 as &mut output_tx so output_tx itself must be mut.

This raises a question, what happens in the case we don't list the captures separately?

|| process(&move(input.vec), &mut move(output_tx), move(&cx.data));

I suppose this would error unless output_tx in the parent scope is also declared as mut?

And similarly, if the content of move is an rvalue (i.e. not a place expression), taking &mut should be always allowed?

|| f(&mut move({ output_tx }))

View changes since the review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, fixed the original point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this would error unless output_tx in the parent scope is also declared as mut?

Actually, I missed this observation, I would have expected it to compile, but it's worth stating explicitly either way (rvalues are generally mutable, and move() is an rvalue).

@nikomatsakis nikomatsakis left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desired feedback: choice of syntax

View changes since this review

## Summary
[summary]: #summary

Add `move($expr)` syntax inside closures, async blocks, and generators. A `move($expr)` evaluates the expression at closure-creation time and captures the result by value. This gives precise control over what a closure captures and when, without needing temporary variables outside the closure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desired feedback: choice of syntax

I'm not convinced that move($expr) is the right syntax. Here are some alternatives keywords I've considered:

  • capture($expr) (new keyword, though)
  • super($expr) (heavily overloaded keyword, I generally prefer move as it is already associated with closure capture rules, but open to others' takes)

And then there is the question of () vs {} (thanks @juntyr for raising it earlier!)

  • move($expr)
  • move { $expr }

@nikomatsakis nikomatsakis Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary goals I think are clarity of semantics -- I am not sure if move conveys that well or not. capture strikes me as the most clear, but the potential conflict with existing code may be a major issue.

I suppose that capture { $expr } could be done as a "contextual keyword" and is unlikely to conflict with existing structs. I generally dislike contextual keywords.

@steffahn steffahn Jun 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, move(x) is bad because it’s too similar to C++’s std::move(x) but it does something entirely different.

Also regarding move { $expr }, I think that

move {
    block_contents()}

might look too similar to

move || {
    block_contents()}

I personally would like a new keyword like capture. I’m not a big fan of recycling keywords for entirely different purposes, anyway. move for closures was to indicate that implicit closure captures should all be done by moving. This new thing is for explicit closure captures (and fully flexible w.r.t. whether things are captured by moving, borrowing, or other preprocessing steps).

I’m unsure about style of parentheses. Both capture($expr) and capture { $expr } seem reasonable. The latter is perhaps syntactically more noticeable without syntax highlighting. The only downside I could see is that “capture” is a bit long.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that capture { $expr } could be done as a "contextual keyword" and is unlikely to conflict with existing structs.

Haven't we learnt from try { ... } 😅? As an expression capture cannot just be a contextual keyword.

And yes struct capture with a small c does exist.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is not so much whether it exists as whether it's widely used; it would require a new edition obviously but the transition is possible.

@teohhanhui teohhanhui Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a user this feels more natural to me:

move || foo(super { expr.clone() })

super seems like the natural fit to refer to the outer scope.

move is too overloaded in the context of closures.

And { ... } feels more like "scope", whereas (...) really feels like function call syntax.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda orthogonal, but I remember this being mentioned with syntax similar to this before:

move(a, mut b) || {}

Was there a decision to not re-use/extend the implicit part? I thought it made it more clear as to the effect happening at creation-time, rather then during body-execution.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a decision to not re-use/extend the implicit part? I thought it made it more clear as to the effect happening at creation-time, rather then during body-execution.

This is answered in line 390 "Why this design over explicit capture clauses?"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern with name(expr) is that there is no graphical hint that evaluation order is being affected. This feels like a major gotcha in reading Rust code. I assume there were similar arguments around expr.await. I suspect that await has a bit more innocuous of an effect on understanding control flow, particularly when it exists inside of an async block / fn. Here, something from inside the middle of a closure is actually running as part of the closure's construction. There are no hints to expect this when looking at the top, requires full inspection to identify the presence, and is likely less common / familiar for people approaching Rust such that they would likely guess wrong what is happening.

The closest syntactic analog feels like a closure itself which is why I lean towards name { expr } or even name || { expr } (e.g. capture || { data.clone() })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea of closures being quasiquotation (#3968 (comment)) is also a good analogy for what could inspire the syntax (which I guess would be closest to name { expr }?)

Comment thread text/0000-move-expressions.md Outdated
Comment on lines +359 to +360
## Prior art
[prior-art]: #prior-art

@steffahn steffahn Jun 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, if closure syntax is viewed as a form of quasiquotation, then these move(…) expressions basically just antiquotation, right? I’m not sure what the best concrete prior art to cite on this would be but I believe the comparison is quite useful.

View changes since the review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't add anything for this, I wouldn't exactly call it prior art, but I guess we could add the comparison.

Comment thread text/0000-move-expressions.md
Comment thread text/0000-move-expressions.md
Comment thread text/0000-move-expressions.md Outdated
@traviscross traviscross added the P-lang-drag-1 Lang team prioritization drag level 1. label Jun 10, 2026
@nikomatsakis

Copy link
Copy Markdown
Contributor Author

I've pushed a number of edits in response to comments made by folks on the RFC. I'm going to "resolve" the relevant conversations that are now captured in FAQs etc.


### A common pattern: listing captures at the top

When you want to be fully explicit about what a closure captures, you can list all captures at the top of the body:

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not need to be this PR, but if one is intending to be fully-explicit it would be nice to have a "capture nothing I didn't write a capture(foo) expression for" opt-in on the closure syntax so that if you're not actually fully-explicit you can get an error about it and find out, rather than accidentally capture something.

View changes since the review

}
```

The outer `move(...)` captures `v.clone()` into the outer closure. The inner `move(...)` then moves that value from the outer closure into the inner closure.

@Skgland Skgland Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this a bit confusing.

move(move(v.clone()))
          ^^^^^^^^^ argument expression of inner move
     ^^^^^^^^^^^^^^^^ inner move and argument expression of outer move
^^^^^^^^^^^^^^^^^^^^^ outer move

I would expect the outer move to capture move(v.clone()) from inside the outer closure.
In the outer closure I would then expect the inner move to capture v.clone() from the surrounding context.

View changes since the review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would help to show the desugaring in two steps

|| {
    || {
        move(move(v.clone())).len()
    }
}

desugars as:

|| {
    let tmp_inner = move(v.clone());
    move_capture(tmp_inner) || {
        tmp_inner.len()
    }
}

desugars as:

{
    let tmp_outer = v.clone();
    move_capture(tmp_outer) || {
        let tmp_inner = tmp_outer;
        move_capture(tmp_inner) || {
            tmp_inner.len()
        }
    }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO nested move() warrants at minimum a complexity-group clippy lint, it is too complicated for human consumption, and should better be split into multiple non-nested move() as

|| {
    let tmp_inner = move(v.clone());
    || {
        move(tmp_inner).len()
    }
}

// `tx` is still usable here
```

### Common patterns with cloning

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Not a question) Just wanted to say that I really like this section 👍

(Makes me ponder having the template include something like "Is there something you'd put in a Rust-by-Example page about your feature?" to prompt more bits like this in other RFCs.)

View changes since the review

This idea has been discussed multiple times in the Rust community:

- [Zachary Harrold proposed it on Zulip][z1] using the `super` keyword, and created a proc-macro prototype called [soupa](https://crates.io/crates/soupa).
- [@simulacrum proposed using `move`][z2] instead of `super`, which better aligns with Rust's existing terminology.

@Mark-Simulacrum Mark-Simulacrum Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll selfishly flag this comment as well - #3680 (comment) :)

There's some possibly useful responses after that I think, though I believe they broadly align with where this RFC's concrete proposal has landed at.

View changes since the review


### Evaluation order

When a closure contains multiple `move(...)` expressions, the temporaries are evaluated in source order (left-to-right, top-to-bottom) at closure-creation time:

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: While it's in some ways obvious, it might be nice to include a note here about nested closures, since of course nested ones aren't exactly evaluated in source order because it depends when the inner closures run.

View changes since the review

|| process(foo(bar()).move)
```

When does `bar()` execute? It must be at closure-creation time, but the postfix position suggests it's part of the normal call-time evaluation. A prefix `move(...)` makes the scope of early evaluation explicit.

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another excellent call-out! Agreed that a "warning up-front to a linear reader" is important here.

I think this specifically pushes me towards braces in the syntax, because typically (perhaps unintentionally) things that affect the meaning of the whole scope are in braces, like how it's unsafe { ... } because it affects the scope not the resulting value.

And thus being || capture { foo.clone() }.clone() with braces here makes sense to me.

View changes since the review


This is useful when you need a fresh owned copy per call but wish to have the closure own the value (in this example, the closure needs to own the `Rc<SharedConfig>` so it can satisfy the `'static` bound).

## Reference-level explanation

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: what's the closure capture behaviour of a capture expression in a nested thunk?

For example, if I have

let my_vec = ...;
foo(async {
    bar(|| move(my_vec.len()))
});

Is my_vec captured by reference in the outer thunk?

View changes since the review


The type of a `move($expr)` expression within the closure body is the type of `$expr`. Type inference works as normal. The temporary's type is inferred from the expression, and the use sites within the closure see that type.

### Place expression semantics

@scottmcm scottmcm Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a place expression for things like ptr::addr_of!?

View changes since the review

@Nadrieril Nadrieril Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be yea, the explanatory desugaring suggests so. addr_of is &raw const anyway, and &raw const move(..) surely should work like &move(..).

EDIT: I hadn't read the section in question, expecting the answer to be "it's always a place expression", see my comment below


### Place expression semantics

Syntactically, `move($expr)` parses as a value expression. However, like other value expressions (e.g., function calls), it can be promoted to a place expression by the compiler when used in a place context (e.g., `&move(...)` or `&mut move(...)`). The temporary that is introduced is declared as mutable, so `&mut move(x)` is always valid without the parent binding of `x` being declared `mut`.

@Nadrieril Nadrieril Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think move(..) must be a place expression. With what you suggest, &mut move($expr) would create a temporary to store the contents of move($expr):

let mut tmp = move($expr);
&mut tmp

which means that the mutation is not preserved across calls to the closure. In fact here if the type of tmp is not Copy, you can't call the closure twice.

Moreover, value-to-place coercion doesn't happen on the lhs of an assignment, yet we want this to work:

let mut x = 42;
let f = || move(x) += 1;

View changes since the review

@Nadrieril Nadrieril Jun 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is further justified by the proposed desugaring: it says the move(..) expression is replaced by a local variable, which is a place expression. (if I may shill my own work) Mine replaces it with a struct field, also a place expression.


### Feature gate

This feature is gated behind `#![feature(move_expressions)]`. No edition change is required since `move` is already a keyword in all editions.

@TaKO8Ki TaKO8Ki Jun 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f(); // works again — data is borrowed, not consumed
```

**`move(x.clone()).clone()` — clone once at creation, clone again per call (Fn / FnMut)**

@epage epage Jun 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect the existence of &move(x.clone()) would be surprising to people who might instead write move(x.clone()).clone(). If possible, might be worth a clippy lint.

View changes since the review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

I-lang-nominated Indicates that an issue has been nominated for prioritizing at the next lang team meeting. P-lang-drag-1 Lang team prioritization drag level 1. T-lang Relevant to the language team, which will review and decide on the RFC.

Projects

None yet

Development

Successfully merging this pull request may close these issues.