For example (taken from the Rust docs):
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
This is not a question about what move
does, but about why it is necessary to specify.
In cases where you want the closure to take ownership of an outside value, would there ever be a reason not to use the move
keyword? If move
is always required in these cases, is there any reason why the presence of move
couldn't just be implied/omitted? For example:
let v = vec![1, 2, 3];
let handle = thread::spawn(/* move is implied here */ || {
// Compiler recognizes that `v` exists outside of this closure's
// scope and does black magic to make sure the closure takes
// ownership of `v`.
println!("Here's a vector: {:?}", v);
});
The above example gives the following compile error:
closure may outlive the current function, but it borrows `v`, which is owned by the current function
When the error magically goes away simply by adding move
, I can't help but wonder to myself: why would I ever not want that behavior?
I'm not suggesting anything is wrong with the required syntax. I'm just trying to gain a deeper understanding of move
from people who understand Rust better than I do. :)
Keyword move move converts any variables captured by reference or mutable reference to variables captured by value. move is often used when threads are involved. move is also valid before an async block.
Rust's closures are anonymous functions you can save in a variable or pass as arguments to other functions. You can create the closure in one place and then call the closure elsewhere to evaluate it in a different context. Unlike functions, closures can capture values from the scope in which they're defined.
There are actually a few things in play here. To help answer your question, we must first understand why move
exists.
Rust has 3 types of closures:
FnOnce
, a closure that consumes its captured variables (and hence can only be called once),FnMut
, a closure that mutably borrows its captured variables, andFn
, a closure that immutably borrows its captured variables.When you create a closure, Rust infers which trait to use based on how the closure uses the values from the environment. The manner in which a closure captures its environment depends on its type. A FnOnce
captures by value (which may be a move or a copy if the type is Copy
able), a FnMut
mutably borrows, and a Fn
immutably borrows. However, if you use the move
keyword when declaring a closure, it will always "capture by value", or take ownership of the environment before capturing it. Thus, the move
keyword is irrelevant for FnOnce
s, but it changes how Fn
s and FnMut
s capture data.
Coming to your example, Rust infers the type of the closure to be a Fn
, because println!
only requires a reference to the value(s) it is printing (the Rust book page you linked talks about this when explaining the error without move
). The closure thus attempts to borrow v
, and the standard lifetime rules apply. Since thread::spawn
requires that the closure passed to it have a 'static
lifetime, the captured environment must also have a 'static
lifetime, which v
does not outlive, causing the error. You must thus explicitly specify that you want the closure to take ownership of v
.
This can be further exemplified by changing the closure to something that the compiler would infer to be a FnOnce
-- || v
, as a simple example. Since the compiler infers that the closure is a FnOnce
, it captures v
by value by default, and the line let handle = thread::spawn(|| v);
compiles without requiring the move
.
It's all about lifetime annotations, and a design decision Rust made long ago.
See, the reason why your thread::spawn
example fails to compile is because it expects a 'static
closure. Since the new thread can run longer than the code that spawned it, we have to make sure that any captured data stays alive after the caller returns. The solution, as you pointed out, is to pass ownership of the data with move
.
But the 'static
constraint is a lifetime annotation, and a fundamental principle of Rust is that lifetime annotations never affect run-time behavior. In other words, lifetime annotations are only there to convince the compiler that the code is correct; they can't change what the code does.
If Rust inferred the move
keyword based on whether the callee expects 'static
, then changing the lifetimes in thread::spawn
may change when the captured data is dropped. This means that a lifetime annotation is affecting runtime behavior, which is against this fundamental principle. We can't break this rule, so the move
keyword stays.
To give us the freedom to change how lifetime inference works, which allows for improvements like non-lexical lifetimes (NLL).
So that alternative Rust implementations like mrustc can save effort by ignoring lifetimes.
Much of the compiler assumes that lifetimes work this way, so to make it otherwise would take a huge effort with dubious gain. (See this article by Aaron Turon; it's about specialization, not closures, but its points apply just as well.)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With