Closures
Where this fits: A closure is Rust's anonymous function — the foundation
behind C#'s Func<>, Action<>, and lambda expressions. Read this module
right after 12_traits: closures are values whose "callability" is
expressed through the Fn, FnMut, and FnOnce traits, so the trait
vocabulary from module 12 is exactly what you need here. Ownership and
borrowing (modules 05 and 06) decide how a closure grabs the variables
around it, so we lean on those ideas constantly.
A closure is an anonymous function that can capture variables from the
scope where it is defined. That capturing ability is the whole point — it is
what separates a closure from a plain fn.
let factor = 3;
let scale = |n| n * factor; // captures `factor` from the surrounding scope
println!("{}", scale(10)); // 30In C#: This is a lambda. int factor = 3; Func<int,int> scale = n => n * factor;
C# lambdas also capture surrounding variables (the compiler builds a hidden
"display class"). Rust does the same conceptually, but it makes the capture
mode part of the type system instead of always boxing everything onto the heap.
|args| body syntax and type inference
let add = |a, b| a + b; // expression body; no annotations needed...
let sum = add(2, 3); // ...because this call infers a, b, and the result as i32
let mul = |a: i32, b: i32| { a * b }; // block body (use {} for multiple statements)
let make = |a: i32, b: i32| -> i32 { a + b }; // fully annotated
let now = || println!("tick"); // no parameters
now();
println!("sum = {sum}, mul = {}, make = {}", mul(4, 5), make(3, 4)); // sum = 5, mul = 20, make = 7- Parameters go between the pipes:
|a, b|. - A single-expression body needs no braces; multi-statement bodies use
{ }. - Types are usually inferred from how the closure is used, so annotations are
optional (unlike a top-level
fn, which must annotate every parameter).
One catch: a closure's parameter types are inferred from the first call and
then locked in. This does not compile, because x is inferred as String at
the first call and then reused with an i32:
let identity = |x| x;
let s = identity(String::from("hi")); // x is now String
// let n = identity(5); // ERROR: expected String, found integerIn C#: Same lambda syntax family ((a, b) => a + b, () => Console.WriteLine("tick")).
C# also infers lambda parameter types from the target delegate type rather than
from the first call, but the end result — "you rarely annotate" — feels familiar.
The three capture modes
When a closure uses a variable from its environment, the compiler picks the least restrictive capture that still works, in this order:
| Mode | Rust borrow | When it is chosen |
|---|---|---|
| By shared reference | &T | The closure only reads the variable. |
| By mutable reference | &mut T | The closure mutates the variable. |
| By value (move) | T | The closure consumes the variable (or you force it with move). |
// 1) read only -> captured by &
let name = String::from("Ferris");
let greet = || println!("Hi, {name}");
greet();
println!("{name}"); // still valid: only borrowed
// 2) mutate -> captured by &mut (note the closure binding must be `mut`)
let mut count = 0;
let mut bump = || count += 1;
bump();
bump();
println!("{count}"); // 2
// 3) consume -> captured by value
let data = String::from("payload");
let consume = || drop(data); // takes ownership of `data`
consume();
// println!("{data}"); // ERROR: `data` was moved into the closureThese are exactly the borrowing rules from modules 05/06 applied
automatically: while bump holds a &mut count, you cannot also borrow count
elsewhere until the closure's last use.
In C#: C# always captures by reference to a hidden field (a mutable shared
cell), so a captured int modified inside a lambda is visible outside it — and
captures never move ownership. Rust instead distinguishes read / mutate / consume
and enforces it at compile time, which is why a "moved into the closure" error
has no C# equivalent.
move forces ownership transfer
The move keyword tells the closure to take ownership of everything it
captures, regardless of whether a borrow would have sufficed:
let name = String::from("Ferris");
let greet = move || println!("Hi, {name}"); // `name` moved in, even though we only read it
greet();
// println!("{name}"); // ERROR: ownership moved into the closuremove does not change what the closure does with the value — it changes who
owns it. You need it whenever the closure must outlive the current scope: when
returning a closure, spawning a thread (module 16), or handing work to an async
task (modules 17/32). For Copy types (i32, bool, ...) move copies
rather than invalidates the original.
In C#: There is no move. The captured variable lives in a heap display
class shared between the lambda and the enclosing method, so lifetime "just
works" via the GC. Rust has no GC, so move is how you say "this closure now
owns its data and is safe to send elsewhere."
Fn, FnMut, FnOnce — the callable traits
Every closure automatically implements one or more of three traits, decided by how it uses its captures. These traits are the type-system handle you use to accept or return closures.
| Trait | Receiver | Call it... | Captures it can hold | Closest C# type |
|---|---|---|---|---|
FnOnce | self | at least once | may consume them | Func/Action you invoke once |
FnMut | &mut self | many times | may mutate them | a Func/Action over mutable state |
Fn | &self | many times | only reads them | a pure Func/Action |
They form a hierarchy: every Fn is also FnMut, and every FnMut is also
FnOnce. So Fn is the most permissive to call (callable repeatedly, even
through a &), while FnOnce is the most permissive to implement (any closure
qualifies). Pick the loosest bound your function actually needs: require
FnOnce if you call it once, FnMut if you call it repeatedly and let it mutate,
Fn if you only read.
let read_only = |x: i32| x + 1; // Fn (also FnMut, also FnOnce)
let mut total = 0;
let mutating = |x: i32| { total += x; total }; // FnMut (also FnOnce)
let s = String::from("hi");
let consuming = move || s; // FnOnce only (moves `s` out)In C#: C# has a single delegate family — Func<...> returns a value,
Action<...> returns void, and you can invoke either as many times as you
like. Rust splits that one idea into three traits because it must track whether
calling the closure mutates or consumes its captured state. Rough mapping:
Fn ≈ a side-effect-free Func/Action, FnMut ≈ one that updates captured
state each call, FnOnce ≈ one you are only allowed to invoke once.
Passing closures into functions
There are two families of options: static dispatch (monomorphized, zero-cost) and dynamic dispatch (a trait object behind a pointer).
Static dispatch: impl Fn or a generic bound
// `impl Fn` in argument position — concise:
fn apply(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
// Equivalent explicit generic bound — use this when you reuse the type `F`:
fn apply_generic<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(x)
}Both generate a specialized copy of the function per closure type (like C# generic specialization for value types). No heap allocation, no indirection. This is the default you should reach for.
Dynamic dispatch: &dyn Fn or Box<dyn Fn>
Use a trait object when the concrete closure type must be erased — e.g. storing
many different closures together in a Vec, or avoiding code bloat:
fn apply_ref(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
f(x) // borrowed trait object: caller keeps ownership
}
// A heterogeneous collection of closures needs a uniform, owned type:
let ops: Vec<Box<dyn Fn(i32) -> i32>> = vec![
Box::new(|x| x + 1),
Box::new(|x| x * 2),
];
for op in &ops {
println!("{}", op(10)); // 11, then 20
}&dyn Fn borrows; Box<dyn Fn> owns the closure on the heap. Both add one
pointer indirection (a vtable lookup), exactly like calling through an interface.
In C#: A Func<int,int> parameter is always a delegate object — i.e. it
behaves like Rust's Box<dyn Fn> / &dyn Fn (dynamic dispatch, heap-backed).
Rust's impl Fn / generic-bound form has no direct C# analogue; it is closer to
the JIT specializing a generic, giving you delegate-like ergonomics with inlined,
allocation-free calls.
Returning a closure
Because each closure has a unique, unnameable type, you return one with
impl Fn (static) or Box<dyn Fn> (dynamic). Returned closures almost always
need move, since the captured values must outlive the function:
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y // `x` is moved into the returned closure
}
let add5 = make_adder(5);
println!("{}", add5(10)); // 15If you need to return different closures from different branches, the single
impl Fn type won't do — box them so both branches share one type:
fn op(add: bool) -> Box<dyn Fn(i32) -> i32> {
if add { Box::new(|x| x + 1) } else { Box::new(|x| x - 1) }
}In C#: This is a factory method returning a delegate:
Func<int,int> MakeAdder(int x) => y => x + y;. C# always returns a heap
delegate (the Box<dyn Fn> style); Rust additionally offers the zero-cost
impl Fn return when a single concrete type suffices.
A capture-then-borrow compile error (and the fix)
This is the classic closure error for newcomers. A closure that mutates a
captured variable holds a &mut borrow of it for as long as the closure is
alive — so you cannot touch that variable in between calls:
fn main() {
let mut count = 0;
let mut bump = || count += 1; // closure borrows `count` mutably here
bump();
println!("{count}"); // ERROR: cannot borrow `count` as immutable
// because it is also borrowed as mutable by `bump`
bump();
}The compiler complains because bump still needs its &mut count for the second
bump() call, while the println! tries to take a &count at the same time —
violating the "one &mut xor many &" rule from module 06.
Fix A — let the closure's borrow end first. Finish all calls, then read the
value. After the last use of bump, its mutable borrow is released (non-lexical
lifetimes), so the read is fine:
fn main() {
let mut count = 0;
let mut bump = || count += 1;
bump();
bump(); // last use of `bump`; its &mut borrow ends here
println!("{count}"); // OK: 2
}Fix B — scope the closure so its borrow is dropped before you read:
fn main() {
let mut count = 0;
{
let mut bump = || count += 1;
bump();
} // `bump` (and its &mut borrow) dropped here
println!("{count}"); // OK: 1
}In C#: None of this arises — C# lambdas capture a shared mutable cell, so you
can freely read and write count from both the lambda and the surrounding code
at any time. The trade-off is that C# cannot catch data races this way; Rust's
borrow rules are what make closures safe to send across threads.
Closures are already everywhere in this course
You have met (or will meet) closures throughout the later modules — this module just names the mechanics:
16_concurrency/17_async_intro/32_background_tasks—thread::spawn, async tasks, and timers all takemoveclosures so the work owns its data.20_database_postgres,24_configuration,27_error_handling_advanced,29_authentication— closures appear as connection/transaction callbacks, config builders,.map_err(|e| ...)error adapters, and middleware handlers.36_iterators(next) —.map,.filter,.fold, and friends are built on closures. We deliberately avoid iterator adaptors in this module's tasks so you focus on closures themselves first.
Key takeaway
A closure is an anonymous function that captures its environment. The
compiler picks the loosest capture mode (&, &mut, or by value), and move
forces ownership transfer when the closure must outlive its scope. Callability is
expressed through three traits — Fn (reads, call many times), FnMut
(mutates, call many times), FnOnce (consumes, call once) — which generalize
C#'s single Func/Action family. Accept and return closures with impl Fn /
generic bounds for zero-cost static dispatch, or &dyn Fn / Box<dyn Fn> when
you need the delegate-like flexibility of dynamic dispatch.