Deep Rust #1: Closures
3 min read
Rust has unique characteristics that are easy to overlook when you come from other languages. To bridge this gap I started doing one quiz a day, using each one as an excuse to dig deeper into the features I tend to avoid.
The past two days I tackled quiz 36 and quiz 26, both involving closures. Quiz 36 in particular bugged me. Consider the following code:
fn call(mut f: impl FnMut() + Copy) {
f();
}
fn g(mut f: impl FnMut() + Copy) {
f();
call(f);
f();
call(f);
}
fn main() {
let mut i = 0i32;
g(move || {
i += 1;
print!("{}", i);
});
}
To understand this I needed some context on closures. What does move do? What is the FnMut trait?
What is a closure?
Closures are anonymous functions that can be saved in a variable or passed as arguments to other functions
Unlike normal functions, closures can capture values from the scope they are defined in and there is no need for type annotation, since in most cases types are infered at compile-time.
The real deal about closures is that they can capture values from the environment, and it can do so in three ways depending on what the body does:
- borrow immutable
- borrow mutable
- taking ownership with the
move
So we have the first piece of the puzzle: the closure takes ownership of i. But ownership alone doesn't tell the full story, what the closure does with that value inside its body matters too.
The Fn traits
The way a closure captures and handles values from the environment affects which traits the closure implements. These are the traits that can be implemented:
FnOnce, applies to closures that can be called onceFnMut, applies to closures that don't move captured values out of their body but might mutate the captured values. It can be called more than onceFn, applies to closures that don't move captured values out of their body and don't mutate captured values, as well as closures that capture nothing from the environment. It can be called more than once without mutating the environment.
| Trait | Body does | Can be called |
|---|---|---|
FnOnce |
Moves captured values out | Only once |
FnMut |
Mutates but doesn't move out | Multiple times (mutably) |
Fn |
Neither mutates nor moves out | Multiple times |
Back to the quiz
Now we can apply this back to the quiz. The closure body does i += 1: it mutates i but never moves i out of the body. That rules out FnOnce and lands us on FnMut. And since i is i32 (which is Copy) and the closure captured it by move, the closure itself is Copy.
This is the key: when g passes f to call(f), f is copied, not moved. Each copy of the closure has its own independent i.
Tracing through the execution:
f()runs the closure and its capturedibecomes 1.call(f)copiesfand executes the copy — the copy'sibecomes 2, but the originalfstill holdsi = 1.f()runs the original closure again and itsibecomes 2.call(f)copiesfa second time and executes the copy — itsibecomes 3.
An important remark is that move and Fn/FnMut/FnOnce closures are almost orthogonal:
movevs non-moveis about whether the fields of the compiled struct have the same type as the original vs are referencesFn/FnMut/FnOnceis about whether the call method of the compiled struct has a receiver which is&selfvs&mut selfvsself