What a closure is¶
Level: 101 → 201 · working knowledge
One line: A closure is a function that can also see the variables around where it was written — so the compiler writes you a struct holding them, which is why every closure has a type of its own and why its size is exactly what it captured.
fn main() {
let bonus = 10;
let with_bonus = |n: i32| n + bonus; // `bonus` came along
println!("{}", with_bonus(1)); // 11
}
Two pipes, a parameter list, an expression. The braces are optional for a single expression, and the parameter types are usually inferred, so the same thing is often written |n| n + bonus.
The capture is the whole difference¶
Rust already has functions, and you can declare one anywhere — including inside another function. What you cannot do is look outward from it:
fn main() {
let bonus = 10;
// fn with_bonus(n: i32) -> i32 { n + bonus } // E0434
let with_bonus = |n: i32| n + bonus; // fine
println!("{}", with_bonus(1)); // 11
}
error[E0434]: can't capture dynamic environment in a fn item
--> scratch.rs:3:40
|
3 | fn with_bonus(n: i32) -> i32 { n + bonus }
| ^^^^^
|
= help: use the `|| { ... }` closure form instead
"Can't capture dynamic environment in a fn item" is the whole definition read backwards. A fn is one item, compiled once, with no per-call baggage; a closure is an item plus the baggage. That is also the reason the syntax is different: |n| marks the thing that is allowed to look around it.
A closure is a struct the compiler wrote¶
Not a metaphor. The compiler generates an anonymous type with one field per captured variable and an impl of the call traits on it, so a closure's size is the size of what it captured — nothing else:
|n| n + 1 captured nothing 0 bytes
move |n| n + bonus captured one i32 4 bytes
move || name.len() captured a String 24 bytes
|n| n + bonus borrowed one i32 8 bytes
|| name.len() borrowed a String 8 bytes
A closure that captured nothing is zero-sized — smaller than the 8-byte fn pointer you might have reached for instead, because there is nothing to point at. Passing it costs no memory traffic at all, and the call is a direct call the optimizer can inline.
The move rows are the ones to read twice: 24 bytes is size_of::<String>(), so what that closure holds is the String itself — pointer, length, capacity — and not a reference to it. Drop the move and the field becomes a reference, so both fall to one pointer wide whatever they captured. Which of the two you get is the move keyword's job.
Every closure has its own type¶
If the compiler writes a fresh struct per closure, two closures cannot share a type, however identical their text:
fn main() {
let bonus = 1;
let flag = true;
// let f = if flag { |x: i32| x + bonus } else { |x: i32| x + bonus }; // E0308
let f: Box<dyn Fn(i32) -> i32> = if flag {
Box::new(move |x: i32| x + bonus)
} else {
Box::new(move |x: i32| x * bonus)
};
println!("{}", f(1)); // 2
}
error[E0308]: `if` and `else` have incompatible types
|
4 | let f = if flag { |x: i32| x + bonus } else { |x: i32| x + bonus };
| ------------------ ^^^^^^^^^^^^^^^^^^ expected closure, found a different closure
|
= note: no two closures, even if identical, have the same type
= help: consider boxing your closure and/or using it as a trait object
"No two closures, even if identical, have the same type" is rustc's own sentence, and the help line is the fix: to put two closures in one variable, one Vec, or one struct field, you need a trait object — Box<dyn Fn(i32) -> i32> — because that is the only thing wide enough to hold both.
What a signature can say about one¶
Three spellings, and the choice is between code size and indirection.
| Spelling | What it means | Cost |
|---|---|---|
fn f<F: Fn(i32) -> i32>(op: F) |
generic — one stamped-out copy of f per closure type |
no indirection; larger binary |
fn f(op: impl Fn(i32) -> i32) |
the same thing, without naming the parameter | identical; cannot be turbofished |
fn f(op: Box<dyn Fn(i32) -> i32>) |
one type at run time, whatever closure is inside | an allocation and a virtual call |
fn f(op: fn(i32) -> i32) |
a bare function pointer — not a closure type | 8 bytes; refuses anything that captured |
The last row is the trap. A closure that captures nothing coerces to a fn pointer, so apply_ptr(41, |n| n + 1) compiles and reads like proof that closures are function pointers. Add one captured variable and it stops:
error[E0308]: mismatched types
|
4 | println!("{}", apply_ptr(41, |n| n + bonus));
| --------- ^^^^^^^^^^^^^ expected fn pointer, found closure
|
note: closures can only be coerced to `fn` types if they do not capture any variables
|
4 | println!("{}", apply_ptr(41, |n| n + bonus));
| ^^^^^ `bonus` captured here
A fn pointer is an address. There is nowhere in it to keep bonus.
If you are coming from another language¶
- Python.
lambda n: n + bonusis the same idea and Python's version is the looser one, which is worth knowing precisely because the code looks identical. A Python closure captures the variable, not the value: rebindbonusafter building the lambda and the lambda sees the new number — the late-binding surprise everyone meets in[lambda: i for i in range(3)], where all three functions return2. Rust captures the place too, but the borrow checker will not let you have it both ways: a closure holding&bonusblocks assignment tobonuswhile it lives, andmovetakes a copy so nothing later can change it. So the loop that misbehaves in Python is a compile error or a copy here, never a silent surprise. What transfers otherwise is nearly everything, including thatdefnested in a function does close over its scope — Python has no equivalent of thefn/E0434split above, since every Python function is already a closure. And Python does not distinguish the three call traits at all: a lambda is callable any number of times and nothing tracks whether it consumed what it captured, which is the next page. - ABAP. There is no closure, and the honest translation is a local class: attributes for what would have been captured, a
constructorthat takes them, and one method — the object is the closure, andNEW lcl_scorer( bonus = 10 )->apply( 1 )is the call. That is not an analogy invented for this page; it is what the compiler does for you here, field for field. The two habits that transfer badly: aFORMor a method sees only its own parameters andCLASS-DATA/global state, exactly like thefnrefused above, so ABAP developers reach for a global rather than a capture — andPERFORMcannot be passed as a value, so "supply the operation" is done in ABAP with a subclass or an interface reference. Both are theBox<dyn Fn>row of the table above, arrived at the long way round: an interface reference with one method is a boxed closure, allocation and virtual call included. What changes is that Rust hands you the zero-cost version too —impl Fn— which stamps out a copy per operation and calls it directly, with no object anywhere. - JavaScript / TypeScript.
n => n + bonusbehaves like the Python case (captures the binding, lives on the heap, callable forever). The one thing to unlearn is that a JS closure is always a heap-allocated object with a hidden reference to its scope, so passing one is always a pointer; here it may be zero bytes, and where it is not, you choose whether the environment travels by reference or by value.
Practice¶
What did the compiler write? Make four closures — one capturing nothing, one capturing a u64, one capturing that u64 and a String, one capturing a [u8; 32] with move. Predict size_of_val for each before you print it.
Then two questions the sizes set up. Write two closures with identical source text and identical signatures, and say why let mut f = a; f = b; does not compile. And say which of your four captured by reference and which by value — then check by naming the captured variables after the closure.
Solution
what_a_closure_is_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: what the compiler wrote for you.
//!
//! A closure is a function that can also see the variables around where it was
//! written. The compiler makes that true by writing a struct: one field per
//! captured variable, plus an implementation of one of the closure traits. So
//! the SIZE of a closure is the size of what it captured, and no two closures
//! have the same type even when they have the same signature.
//!
//! rustc --edition 2024 what_a_closure_is_kata.rs -o /tmp/wac && /tmp/wac
use std::mem::size_of_val;
fn main() {
let factor = 3u64;
let label = String::from("times");
let table = [1u8; 32];
let captures_nothing = |n: u64| n + 1;
let captures_one = |n: u64| n * factor;
let captures_two = |n: u64| format!("{n} {label} {factor}");
let captures_array = move |n: usize| table[n % table.len()];
println!("SIZE IS WHAT IT CAPTURED");
println!(" captures nothing {:>3} bytes", size_of_val(&captures_nothing));
println!(" captures one u64 {:>3} bytes", size_of_val(&captures_one));
println!(" captures a &String too {:>3} bytes", size_of_val(&captures_two));
println!(" captures a [u8; 32] {:>3} bytes", size_of_val(&captures_array));
println!();
println!(" A closure that captures nothing is a zero-sized value: there is");
println!(" nothing to store, so it costs no memory at all. Each capture");
println!(" adds exactly its own field, by reference or by value depending");
println!(" on what the body needs and whether `move` was written.");
println!();
println!("EVERY CLOSURE HAS ITS OWN TYPE");
let a = |n: u64| n + 1;
let b = |n: u64| n + 1;
println!(" a(1) = {}, b(1) = {}, and both are {} bytes", a(1), b(1), size_of_val(&a));
println!(" a and b have identical source text and identical signatures,");
println!(" and they are still two different types. `let mut f = a; f = b;`");
println!(" does not compile -- the error names two closures at two");
println!(" different lines. That is why returning one needs `impl Fn` or a");
println!(" `Box<dyn Fn>`: the type has no name you could write down.");
println!();
println!("SO WHAT DOES IT DO?");
println!(" captures_one(7) = {}", captures_one(7));
println!(" captures_two(7) = {}", captures_two(7));
println!(" captures_array(7) = {}", captures_array(7));
println!();
println!(" factor is still usable here: {factor}");
println!(" label is still usable here: {label}");
println!(" Neither was moved, because neither body needed to own it -- the");
println!(" compiler captured both by shared reference. `table` WAS moved,");
println!(" because `move` was written, and naming it below this line would");
println!(" not compile.");
assert_eq!(size_of_val(&captures_nothing), 0);
assert_eq!(size_of_val(&captures_one), 8);
assert_eq!(captures_one(7), 21);
}
Verified output of what_a_closure_is_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
SIZE IS WHAT IT CAPTURED
captures nothing 0 bytes
captures one u64 8 bytes
captures a &String too 16 bytes
captures a [u8; 32] 32 bytes
A closure that captures nothing is a zero-sized value: there is
nothing to store, so it costs no memory at all. Each capture
adds exactly its own field, by reference or by value depending
on what the body needs and whether `move` was written.
EVERY CLOSURE HAS ITS OWN TYPE
a(1) = 2, b(1) = 2, and both are 0 bytes
a and b have identical source text and identical signatures,
and they are still two different types. `let mut f = a; f = b;`
does not compile -- the error names two closures at two
different lines. That is why returning one needs `impl Fn` or a
`Box<dyn Fn>`: the type has no name you could write down.
SO WHAT DOES IT DO?
captures_one(7) = 21
captures_two(7) = 7 times 3
captures_array(7) = 1
factor is still usable here: 3
label is still usable here: times
Neither was moved, because neither body needed to own it -- the
compiler captured both by shared reference. `table` WAS moved,
because `move` was written, and naming it below this line would
not compile.
The verified output¶
Verified output of what_a_closure_is.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The syntax, next to the function it replaces
fn add_one(n: i32) -> i32 { n + 1 } add_one(41) = 42
|n: i32| n + 1 add_one_closure(41) = 42
|n| n + 1 (types inferred) inferred(41) = 42
2. The difference is the capture: a closure can see the scope around it
bonus = 10, so with_bonus(1) = 11
a plain `fn` written in the same spot cannot: E0434, see the page
3. A closure IS a struct the compiler wrote — its size is what it captured
|n| n + 1 captures nothing 0 bytes
move |n| n + bonus captures one i32 4 bytes
move || name.len() captures a String 24 bytes
for comparison: size_of::<String>() = 24 bytes
for comparison: size_of::<fn(i32) -> i32>()= 8 bytes
a closure that captured nothing is ZERO-SIZED — smaller than a fn pointer.
|n| n + bonus borrows one i32 8 bytes
|| name2.len() borrows a String 8 bytes
the same two closures without `move`: each field is now a reference,
so both are one pointer wide. (calling them: 11 8)
(calling them, so the compiler keeps them: 2 11 3)
4. Which means every closure has its own anonymous type
two closures with identical text are two different types — E0308,
and rustc says so in as many words: "no two closures, even if
identical, have the same type". See the page for the transcript.
5. A closure that captured nothing coerces to a plain `fn` pointer
apply_ptr(41, |n| n + 1) = 42
apply_ptr(41, add_one) = 42
apply_ptr(41, |n| n + bonus) -> E0308: expected fn pointer, found closure
6. Three ways to accept one, and what each costs
fn apply(n, op: impl Fn(i32)->i32) apply(41, |n| n + bonus) = 51
Box<dyn Fn(i32)->i32> boxed(41) = 51
size of the Box 16 bytes (a fat pointer: data + vtable)
`impl Fn` is one stamped-out copy per closure type — no indirection.
`Box<dyn Fn>` is one allocation and a virtual call, and it is what
you need the moment two different closures must share a variable.
7. Higher-order: the caller supplies the operation
add one applied to 20 -> 21
double applied to 20 -> 40
add bonus applied to 20 -> 30
all three live in one Vec because `dyn Fn` erased their three types.
See also¶
- The three closure traits —
Fn,FnMut,FnOnce: which one a closure gets, and what decides it - The
movekeyword — whether the capturedStringabove is the string or a borrow of it - Returning a trait — the
impl Trait/Box<dyn Trait>decision the table above is one instance of CopyvsClone— what a capture does to a value that is notCopyunwrap_or_else— the first closure most people write in Rust, and it is anFnOnce- What a struct is — the thing the compiler is writing for you here
- Iterators are lazy — where most of the closures you write actually go, and what the adapter is allowed to do with yours
Sources¶
Closures ↗ in the Book, and the reference's closure expressions ↗ for the coercion rule quoted above.
Po polsku¶
Polskie materiały najczęściej przedstawiają domknięcie (closure) jako „funkcję anonimową”, a ta nazwa wskazuje na rzecz najmniej istotną. Brak nazwy jest tu drobiazgiem; różnicę robi przechwytywanie (capture) — możliwość zajrzenia do zmiennych z otoczenia, w którym domknięcie zostało napisane. Najlepszym dowodem jest to, że zwykłą funkcję też wolno zadeklarować w środku innej funkcji, tylko że nic z niej nie widać: rustc odpowiada wtedy E0434, „can't capture dynamic environment in a fn item”, i sam podpowiada formę || { ... }. To jest definicja przeczytana od tyłu — fn to jeden element skompilowany raz, bez żadnego bagażu, a domknięcie to ten sam element plus bagaż. Dlatego składnia jest inna: |n| oznacza tę rzecz, której wolno się rozglądać.
Zdanie „domknięcie jest strukturą, którą napisał kompilator” nie jest tu przenośnią. Powstaje anonimowy typ z jednym polem na każdą przechwyconą zmienną i z implementacją cech (traits) wywołania, więc rozmiar domknięcia to dokładnie rozmiar tego, co przechwyciło — i to widać w liczbach: brak przechwyceń to 0 bajtów, jeden i32 z move to 4, a String z move to 24, czyli tyle, ile size_of::<String>() — wskaźnik, długość i pojemność, a więc sam łańcuch znaków, nie referencja do niego. Bez move każde pole staje się referencją i oba przypadki spadają do 8 bajtów, czyli do jednego wskaźnika. Najciekawszy jest jednak wiersz pierwszy: domknięcie, które niczego nie przechwyciło, ma zero bajtów — jest mniejsze niż ośmiobajtowy wskaźnik na funkcję, bo nie ma na co wskazywać. Kto przychodzi z JavaScriptu albo Pythona, ma tu odruch odwrotny: tam każde domknięcie to obiekt na stercie z ukrytym odnośnikiem do zasięgu, więc przekazanie go zawsze kosztuje wskaźnik.
Z „osobnej struktury na każde domknięcie” wynika konsekwencja, o którą rozbija się większość pierwszych programów: każde domknięcie ma własny, anonimowy typ, którego nie da się zapisać. Dwa domknięcia o identycznej treści to dwa różne typy — E0308 i zdanie samego kompilatora, „no two closures, even if identical, have the same type”. Tu potyka się zwłaszcza ktoś z Javy: tam typem lambdy jest interfejs (Function<T, R>), więc flag ? x -> x + 1 : x -> x * 2 przechodzi bez mrugnięcia. W Ruscie Fn(i32) -> i32 nie jest typem, tylko cechą, i żeby dwa domknięcia zmieściły się w jednej zmiennej, jednym wektorze albo jednym polu struktury, trzeba obiektu cechy: Box<dyn Fn(i32) -> i32>, czyli 16 bajtów grubego wskaźnika (dane + tablica metod wirtualnych).
Stąd trzy sposoby przyjęcia domknięcia w sygnaturze i jeden wybór do świadomego podjęcia — między rozmiarem kodu a dodatkowym skokiem w czasie działania. F: Fn(i32) -> i32 oraz impl Fn(i32) -> i32 znaczą to samo (kompilator generuje osobną kopię funkcji dla każdego typu domknięcia: żadnego skoku, za to większy plik wynikowy), a Box<dyn Fn(i32) -> i32> to jedna alokacja i wywołanie wirtualne, za to jeden typ w czasie działania. Czwarty wiersz tabeli, op: fn(i32) -> i32, jest pułapką: domknięcie bez przechwyceń konwertuje się na wskaźnik na funkcję, więc apply_ptr(41, |n| n + 1) kompiluje się i wygląda jak dowód, że domknięcia są wskaźnikami na funkcje. Dołóż jedną przechwyconą zmienną i zostaje E0308 — we wskaźniku, który jest samym adresem, nie ma gdzie schować bonus.
Szukaj po polsku: domknięcie a funkcja anonimowa · przechwytywanie zmiennych ze środowiska · rust closure is a struct · rust E0434 can't capture dynamic environment · rust no two closures have the same type