When you write |x| x + factor, Rust creates an anonymous struct, implements a trait on it, and calls a method.
- The Code
- Dumping the MIR
- The Desugaring Chain
- Anatomy of the Inlined MIR
- The Inlining Barriers
- Non-Inlined MIR: The Real Closure Machinery
- The RustCall ABI Surprise
- Summary: What Closures Actually Require
- Key Takeaways
Four functions. Each uses a closure differently.
fn apply_fn<F: Fn(u32) -> u32>(f: F, x: u32) -> u32 {
f(x)
}
fn apply_fn_once<F: FnOnce(u32) -> u32>(f: F, x: u32) -> u32 {
f(x)
}
#[inline(never)]
fn direct_call() -> u32 {
let factor = 3u32;
let scale = |x: u32| x * factor;
scale(10)
}
#[inline(never)]
fn generic_call() -> u32 {
let factor = 3u32;
let scale = |x: u32| x * factor;
apply_fn(scale, 10)
}
#[inline(never)]
fn fnonce_call() -> u32 {
let factor = 3u32;
let triple = |x: u32| x * factor;
apply_fn_once(triple, 10)
}#[inline(never)] on the *_call functions prevents them from being inlined into main(), so we can inspect each one individually. The helper functions (apply_fn, apply_fn_once) have no annotation — the MIR inliner decides on its own whether to inline them.
Everyone has written closures like these. The question is: what do they become?
Anyone can follow along:
# Unoptimized MIR (shows full closure machinery)
cargo +nightly rustc -- -Zunpretty=mir
# Optimized MIR (shows inlining, constant folding)
cargo +nightly rustc --release -- -Zunpretty=mirWe'll look at three versions of essentially the same closure — |x| x * factor — to see how the MIR changes depending on how the closure is used.
Important: with cargo, you must use --release to see inlining. cargo rustc -- -C opt-level=3 does NOT work — cargo still uses the dev profile regardless of that flag. Without --release, no inlining happens and every closure shows the full machinery. All the "inlined" MIR examples below use --release unless stated otherwise.
Closures are syntactic sugar. Rust desugars every closure into three pieces:
// What you write:
let factor = 3u32;
let scale = |x: u32| x * factor;
let result = scale(10);
// What rustc creates (conceptually):
struct __closure_1 {
factor: u32, // captured variable ("upvar")
}
impl FnOnce<(u32,)> for __closure_1 {
type Output = u32;
fn call_once(self, (x,): (u32,)) -> u32 {
x * self.factor
}
}
let scale = __closure_1 { factor: 3 };
let result = FnOnce::call_once(scale, (10,));Three components:
| Component | What it is | MIR evidence |
|---|---|---|
| Anonymous struct | Fields are captured variables ("upvars") | Aggregate(Closure(...), [_1]) |
| Trait impl | Fn, FnMut, or FnOnce |
<{closure@...} as FnMut<(u32,)>>::call_mut(...) |
| Method call | call, call_mut, or call_once |
Function call terminator in MIR |
The term "upvar" means "upward variable" — a variable captured from an enclosing scope. The closure struct's fields ARE the upvars.
| Mode | Rust syntax | Struct field | Trait |
|---|---|---|---|
| By shared ref | || ... x ... (read only) |
x: &T |
Fn |
| By mutable ref | || ... x += 1 ... |
x: &mut T |
FnMut |
| By value (move) | move || ... or inferred |
x: T |
FnOnce |
Here's what direct_call() looks like in release MIR (cargo +nightly rustc --release -- -Zunpretty=mir):
fn direct_call() -> u32 {
let mut _0: u32;
let _1: u32;
scope 1 {
debug factor => _1;
scope 2 {
scope 3 (inlined direct_call::{closure#0}) {
}
}
}
bb0: {
_1 = const 3_u32;
_0 = const 30_u32;
return;
}
}
That's it. One basic block. One constant.
Notice:
scope 3 (inlined direct_call::{closure#0})— the compiler tells us it inlined the closure body. The closure struct, the trait dispatch, everything — gone._0 = const 30_u32— the entire closure callscale(10)didn't just become aMul— it was constant-folded to30. Rustc computed10 * 3at compile time.- No closure struct. No
Fn::call. No tuple packing. The closure is completely erased.
fn generic_call() -> u32 {
let factor = 3u32;
let scale = |x: u32| x * factor;
apply_fn(scale, 10) // Fn-bounded generic
}MIR (release):
fn generic_call() -> u32 {
let mut _0: u32;
let _1: u32;
let _2: {closure@src/main.rs};
scope 1 {
debug factor => _1;
scope 2 {
debug scale => _2;
}
}
bb0: {
_1 = const 3_u32;
_2 = {closure@...} { factor: &_1 };
_0 = apply_fn::<{closure@...}>(move _2, const 10_u32)
-> [return: bb1, unwind continue];
}
bb1: {
return;
}
}
Not identical. The closure struct is constructed and passed to apply_fn. Even without #[inline(never)] on apply_fn, the MIR inliner refuses to inline it. Why? Because Fn-bounded generic functions have unwind cleanup blocks (the closure must be dropped if Fn::call panics), and the MIR inliner considers them too expensive to inline.
Now pass the same closure through FnOnce:
fn fnonce_call() -> u32 {
let factor = 3u32;
let triple = |x: u32| x * factor;
apply_fn_once(triple, 10) // FnOnce-bounded generic
}MIR (release):
fn fnonce_call() -> u32 {
let mut _0: u32;
let _1: u32;
scope 1 {
debug factor => _1;
let _2: {closure@src/main.rs};
scope 2 {
debug triple => _2;
scope 3 (inlined apply_fn_once::<{closure@...}>) {
}
}
}
bb0: {
_1 = const 3_u32;
_2 = {closure@...} { factor: &_1 };
_0 = <{closure@...} as FnOnce<(u32,)>>::call_once(move _2, const (10_u32,))
-> [return: bb1, unwind continue];
}
bb1: {
return;
}
}
Two things happened:
scope 3 (inlined apply_fn_once::<...>)— theapply_fn_oncewrapper WAS inlined. Its body is simple —FnOnce::call_onceconsumes the closure, so there are no unwind cleanup blocks.FnOnce::call_once(move _2, ...)— but the closure's trait method call was NOT inlined. The struct is constructed, the tuple is packed, and a realcall_oncedispatch remains.
This reveals two inlining barriers, not one:
| Scenario | Helper inlined? | Closure call inlined? | Result |
|---|---|---|---|
Direct call: scale(10) |
N/A | YES | const 30_u32 |
Fn-bounded: apply_fn(scale, 10) |
NO | N/A (blocked) | Calls apply_fn(...) |
FnOnce-bounded: apply_fn_once(triple, 10) |
YES | NO | Calls call_once(...) |
FnMut-bounded: apply_fn_mut(scale, 10) |
NO | N/A (blocked) | Calls apply_fn_mut(...) |
Barrier 1: Fn/FnMut helpers don't inline. apply_fn and apply_fn_mut have unwind cleanup blocks — they must drop the closure if the trait call panics. The MIR inliner considers these too expensive, even without #[inline(never)].
Barrier 2: FnOnce::call_once dispatch doesn't inline. Even after apply_fn_once is inlined (its body has no cleanup — call_once consumes the closure), the call_once trait method itself remains a real function call in the optimized MIR.
Only a direct closure call (scale(10)) fully collapses. The moment you route through any generic helper, some or all of the closure machinery survives in MIR.
Note: In unoptimized MIR (no
--release), nothing inlines — every closure shows the full struct + trait dispatch machinery regardless of bound. The barriers above only appear in release builds.
When the closure ISN'T inlined, you can see the full machinery. Let's look at the closure's own function body — the call_once method:
fn fnonce_call::{closure#0}(_1: &{closure@...}, _2: u32) -> u32 {
debug x => _2;
debug factor => (*((*_1).0: &u32));
let mut _0: u32;
let mut _3: u32;
let mut _4: &u32;
bb0: {
_4 = copy ((*_1).0: &u32);
_3 = copy (*_4);
_0 = Mul(copy _2, move _3);
return;
}
}
Reading this:
_1: &{closure@...}— the first parameter is a reference to the closure struct.(*_1).0accesses field 0 (the capturedfactorreference), then dereferences it._2: u32— the second parameter is the argumentx. But wait — at the call site we passed a tuple(u32,). MIR unpacked it. More on this in the next section.debug factor => (*((*_1).0: &u32))— the debug annotation mapsfactorthrough a double dereference: field 0 of the closure struct is a&u32, then deref to get theu32._0 = Mul(copy _2, move _3)— the closure body:x * *self.factor.
In the MIR dump you'll see this body listed as fn fnonce_call::{closure#0}. This looks like a nested function inside fnonce_call, but it's not — it's the compiler-generated trait method implementation for this closure. These are two names for the same function:
| Context | Name |
|---|---|
| At the call site (trait dispatch) | <{closure@src/main.rs:...} as FnOnce<(u32,)>>::call_once |
| At the definition (function body) | fn fnonce_call::{closure#0} |
Rustc names the body after where the closure literal appears in source (fnonce_call, closure #0), not after the trait method it implements. There is no separate call_once wrapper — the closure body IS the trait method implementation.
let double = |x: u32| x * 2;This closure captures nothing. Its struct is empty — a Zero-Sized Type. In release MIR:
debug double => const ZeroSized: {closure@src/main.rs:58:18: 58:26};
The variable is declared but never assigned. There's nothing to store. Rustc knows the type, emits the debug annotation with const ZeroSized:, and moves on.
Here's something that surprised us when building the backend. Look at the call site again:
_0 = <{closure@...} as FnOnce<(u32,)>>::call_once(move _2, const (10_u32,))
And the closure body signature:
fn fnonce_call::{closure#0}(_1: &{closure@...}, _2: u32) -> u32
The call site passes a tuple (10_u32,). The body expects unpacked u32. This is the RustCall ABI.
| Component | What fn_sig says | What MIR locals show |
|---|---|---|
| Regular function | [arg1, arg2, ...] |
_1=arg1, _2=arg2 |
| Closure (RustCall) | [(arg1, arg2, ...)] — a tuple |
_1=self, _2=arg1, _3=arg2 — unpacked |
For a closure |x: u32| x * 3:
fn_sig().inputs()returns[(u32,)]— 1 element (a tuple wrapping the argument)- But MIR has
_1 = &mut Self,_2 = u32— 2 parameters (self + the unpacked arg)
A backend that trusts fn_sig to count parameters gets the wrong number. The correct count is 1 (self) + tuple_elements.
This also means the backend must unpack the tuple at the call site. The caller passes (u32,), but the callee expects u32. Without tuple unpacking via field extraction, the LLVM types won't match:
expected: func<i32(ptr, i32)>
got: func<i32(ptr, struct<{i32}>)>
Implementing closure support in a MIR backend requires more than "closures are just structs." Here's the full picture:
| Feature | Why |
|---|---|
| Struct types | Closure = struct with upvars as fields |
| Struct construction | Aggregate(Closure(...), [captured_values]) |
| Field access | (_1.0: u32) to read captures inside the body |
| Closure type translation | RigidTy::Closure is a separate type kind from FnDef |
| RustCall ABI handling | fn_sig shows a tuple; MIR locals are unpacked |
| Tuple unpacking at call sites | Caller passes (u32,), callee expects u32 |
| Trait method resolution | <{closure} as FnOnce>::call_once must be found and compiled |
| Associated type aliases | FnOnce::Output must resolve to the concrete return type |
| ZST handling | Closures with no captures are zero-sized — declared but never assigned |
| Name sanitization | {closure#0} contains invalid identifier chars ({, }, #) |
| Mutable references | &mut _6 for passing the closure to call_mut |
That's 11 distinct features. And most of them are invisible when rustc inlines the closure away.
- A closure is an anonymous struct + a trait impl + a method call — all three are visible in unoptimized or non-inlined MIR
- Inlining hides everything — for direct closure calls, rustc inlines and constant-folds the entire thing away
- There are two inlining barriers —
Fn/FnMuthelpers don't inline (cleanup blocks), and even whenFnOncehelpers do inline, thecall_oncetrait dispatch survives - Closures use the RustCall ABI —
fn_sigreports a tuple, MIR locals are unpacked. A backend must reconcile the two - ZST closures are declared but never assigned — they're live in the control flow but have no value in the value map
FnOnce::Outputis an alias type — the return type of a closure call isn't concrete in the signature; it's an associated type projection that must be resolved- The name
{closure#0}is not a valid identifier — backends that use the source name need to sanitize or fall back to the mangled symbol
# Dump optimized MIR for the whole crate (MUST use --release)
cargo +nightly rustc --release -- -Zunpretty=mir
# Dump unoptimized MIR (shows full closure machinery)
cargo +nightly rustc -- -Zunpretty=mir
# Filter for a specific function
cargo +nightly rustc --release -- -Zunpretty=mir 2>&1 | grep -A 50 "fn fnonce_call"
# See the closure body itself
cargo +nightly rustc --release -- -Zunpretty=mir 2>&1 | grep -A 20 "fn.*closure"Note: cargo rustc -- -C opt-level=3 does NOT produce optimized MIR. Cargo ignores -C opt-level for profile selection — it always uses the dev profile unless you pass --release. This is the #1 gotcha when inspecting MIR.
- Rust Reference — Closures: https://doc.rust-lang.org/reference/expressions/closure-expr.html
- rustc Dev Guide — Closure Expansion: https://rustc-dev-guide.rust-lang.org/closure.html
