The Rust Engineering Handbook / Chapter 22
Closures and the Fn Trait Family
Design callback APIs by reading a closure as an environment whose captures determine ownership, mutation, lifetime, thread safety, and call capability.
Read the environment before reading the bound
Consider three callbacks whose bodies differ by one operation:
let label = String::from("settlement");
let inspect = || label.len();
let mut attempts = 0;
let count = || { attempts += 1; };
let report = String::from("complete");
let finish = move || report;
The first reads borrowed state. The second mutates borrowed state. The third moves an owned value out when called. Those actions determine whether the compiler-generated closure type implements Fn, FnMut, or only FnOnce. The move keyword alone does not decide the call trait: a closure may capture by value and still implement Fn if its body only reads the captured value.
This gives callback review a reliable order:
- List the places captured from the surrounding environment.
- Determine how each place is captured: shared borrow, unique/mutable borrow, or by value.
- Determine what each call does with the captured value: read, mutate, or move out.
- Choose the weakest caller requirement that permits the intended number and style of calls.
- Add storage, lifetime, and thread bounds only where the callback crosses those boundaries.
The bound is therefore an effect contract between caller and callable, not a ranking of “better” closure types.
A closure has a unique anonymous type
Each closure expression creates its own anonymous type, even when two closures have identical parameter and return types and capture nothing. Conceptually, the type is a compiler-generated struct whose fields hold captured places, plus compiler-provided implementations of the applicable call traits. The conceptual struct is useful for ownership reasoning; its name and exact layout are not a public Rust interface.
This explains why the following cannot return two arbitrary closure expressions behind one impl Fn:
fn choose(flag: bool) -> impl Fn(u64) -> u64 {
if flag {
|value| value + 1
} else {
|value| value * 2
}
}
impl Trait return syntax requires one hidden concrete type. Non-capturing closures may coerce to the same fn(u64) -> u64 function-pointer type, so annotating or casting both branches can repair this particular example. If branches capture different environments, use an enum that implements the behavior, return Box<dyn Fn(u64) -> u64>, or move selection outside the function. These repairs differ in allocation, openness, and representation.
Closure values are Sized. Their size follows what they capture, subject to compiler representation details. Capturing a large structure by value can make the callable large; boxing it makes the handle pointer-sized but introduces allocation and indirection rather than eliminating the environment.
Capture analysis is place-sensitive
Rust 2021 and later editions support precise capture of disjoint fields in ordinary cases. If a closure reads account.id, it need not capture every field of account. That precision can allow another field to move or borrow independently. The Reference documents important truncation rules around packed fields, raw-pointer dereferences, unions, and Box dereferences; unsafe or representation-sensitive cases deserve direct source consultation.
Capture precision is not an excuse to make a callback reach through a giant service object. A closure written as move || self.client.send(self.config.endpoint.clone()) may capture more architectural authority than the operation needs, complicate lifetimes, block concurrency, and retain expensive resources. Extract narrow values before constructing it:
let client = self.client.clone();
let endpoint = self.config.endpoint.clone();
let send = move || client.send(&endpoint);
This makes the environment reviewable. It can also make Send, Sync, and 'static diagnostics point to the actual dependency rather than an entire aggregate.
Capturing by reference can be cheaper and preserve ownership, but it couples the closure lifetime to the borrowed value. Capturing by value can release the outer scope and support storage, at the cost of transfer or cloning. Decide from lifecycle, not from a blanket rule to add move.
move changes capture mode, not necessarily call count
move requests by-value capture of referenced outer variables, copying those that are Copy and moving other values. It is common for thread entry points and returned callbacks because the new owner cannot borrow a stack frame that will end.
The closure body still determines the call traits. This closure owns prefix but only reads it, so it can be called repeatedly and implements Fn:
let prefix = String::from("ledger");
let format = move |id: u64| format!("{prefix}-{id}");
assert_eq!(format(7), "ledger-7");
assert_eq!(format(8), "ledger-8");
By contrast, move || prefix moves the String out of its environment on the first call. It implements FnOnce, not FnMut or Fn. Cloning inside the closure could make repeated calls compile, but that changes allocation and semantics. Sometimes the intended operation really is one-shot completion, and FnOnce is the strongest expression of it.
The call traits form a capability ladder
Every closure implements FnOnce: the caller can always consume the closure to invoke it if the argument and result types match. Closures that do not move out of captures also implement FnMut; closures that neither mutate nor move out of captures also implement Fn.
The subtrait direction can sound surprising. An Fn callable can be used where FnMut or FnOnce is accepted because a callable that works through shared access can also satisfy a caller allowed mutable or consuming access. An FnMut callable can satisfy an FnOnce caller. The reverse substitutions do not hold.
The receiver tells the story more clearly than a ranking does. A caller with F: Fn(A) -> R can invoke through shared access, so predicates and shared handlers often use it. “Shared” does not mean pure: a captured atomic, lock, or other interior-mutable value can still change. Fn is a receiver guarantee, not a global side-effect guarantee.
With F: FnMut(A) -> R, invocation requires mutable access to the callable. The environment may change between calls, as it does for counters, stateful parsers, and many iterator adapters. With F: FnOnce(A) -> R, the call consumes the callable and may consume its environment; completion actions and ownership handoffs naturally fit that contract.
Choose the bound from the caller’s actual control flow. A function that calls once should accept FnOnce; that admits shared, stateful, and consuming closures. A retry loop needs repeated invocation and therefore at least FnMut. Require Fn only when the caller genuinely needs invocation through shared access. A narrower callback requirement should buy the API a real capability.
The caller’s control flow owns the contract
The lab’s completion helper accepts ownership and calls once:
pub fn complete_with<F, T>(complete: F) -> T
where
F: FnOnce() -> T,
{
complete()
}
This admits move || report, which transfers the report out. A queue that may invoke a callback repeatedly instead needs a mutable receiver:
pub fn retain_with<F>(postings: &mut Vec<Posting>, mut keep: F)
where
F: FnMut(&Posting) -> bool,
{
postings.retain(|posting| keep(posting));
}
The mut keep binding does not mean every supplied closure mutates state. It grants the caller the receiver access required to invoke any FnMut. An ordinary Fn predicate still satisfies the bound.
Retries, cancellation, and panic behavior belong in callback documentation. If the caller may invoke before and after a transient failure, say whether effects must be idempotent. If it may drop without calling during cancellation, a completion closure cannot be the sole owner of an externally required action. If it catches panics, define which invariants remain valid; otherwise let the process or task policy govern unwinding explicitly.
Rejected code can reveal the intended cardinality
The UI fixture requires repeatable shared calls but supplies a closure that moves out its report:
fn call_twice<F>(callback: F)
where
F: Fn() -> String,
{
let _ = callback();
let _ = callback();
}
let report = String::from("settlement report");
call_twice(move || report);
Rust reports E0507 because moving report out is incompatible with calling through Fn. Valid repairs answer different product questions:
- If there is one report and one handoff, change the caller to
FnOnceand call once. - If each call needs an independent owned report, clone explicitly and accept that cost, or generate a fresh report.
- If callers only need to inspect text, return or pass a borrow with a lifetime tied to suitable storage.
- If repeated calls model retries, move idempotent sending behavior into a stateful
FnMutobject rather than pretending the owned payload can be consumed repeatedly.
Changing the bound to silence a diagnostic without checking call cardinality can hide a broken retry or ownership design.
Returning closures preserves or erases identity
Return impl Fn(...) when one function constructs one concrete closure type and callers only need its behavior:
pub fn threshold_callback(maximum: u64) -> impl Fn(&Posting) -> bool {
move |posting| posting.cents <= maximum
}
This stores maximum in the returned environment and keeps static dispatch. Add a lifetime to the opaque return when it borrows input rather than owning its captures.
Return Box<dyn Fn(...)> when construction can yield unrelated closure types or an owning heterogeneous collection is required. The box supplies a sized owning handle; the object erases the concrete closure type. As with other trait objects, borrowed &dyn Fn(...) and &mut dyn FnMut(...) do not inherently allocate.
Use a named struct implementing an ordinary domain trait when the environment deserves inspection, configuration, serialization, metrics, or stable domain vocabulary. Closure syntax is concise, but a public system boundary often benefits from a named type whose invariants can be documented directly.
Stored callbacks make retention visible
The lab queue exposes its complete retention contract:
pub struct CallbackQueue {
callbacks: Vec<Box<
dyn FnMut(&Posting) -> Decision + Send + 'static
>>,
}
The queue owns heterogeneous callbacks, can invoke them mutably more than once, may transfer them between threads, and does not admit environments borrowing short-lived stack data. Each bound corresponds to a real operation. If the queue never crosses threads, Send is unnecessary. If it only runs during a borrowed scope, a lifetime parameter may be better than 'static. If every callback has one concrete type, a generic queue can avoid erasure and allocation.
Retention has operational effects. A callback can keep a file descriptor, large cache, channel sender, or Arc graph alive long after its creator expects. Define removal, shutdown, and drop behavior. Avoid cycles where a registry owns a callback that captures the registry through Arc; use explicit deregistration or weak ownership when the topology requires it.
Function items, function pointers, and closures are different tools
A named function item has its own zero-sized type and can coerce to a function pointer such as fn(&Posting) -> bool. A non-async closure that captures nothing can also coerce to a matching function pointer. Function pointers are useful for C-compatible callback surfaces (with the correct extern ABI), compact tables, and APIs that explicitly forbid environments.
A fn pointer is not a trait object and carries no captured state. Replacing F: Fn(...) with fn(...) can simplify representation but rejects capturing closures and may reduce inlining opportunity. Use it when “no environment” is the contract, not as a habitual callback spelling.
At an FFI boundary, do not pass a Rust closure directly. Use an ABI-safe function pointer plus an explicit context pointer whose allocation, type reconstruction, thread access, and destruction are specified. Prevent unwinding across an ABI that does not permit it.
Thread bounds follow the environment
A closure implements Send and Sync according to what and how it captures, broadly following the rules for an equivalent struct. Moving a closure into std::thread::spawn requires an owned environment that is Send and normally 'static because the thread may outlive the current stack frame. Scoped threads can admit bounded borrows because the scope proves joining before borrowed data expires.
An Rc<T> capture prevents Send; replacing it with Arc<T> only solves ownership transfer if T also meets the required thread-safety bounds. Wrapping mutable data in Arc<Mutex<T>> makes synchronized shared mutation possible, but it also creates contention, poisoning/panic decisions, lock-order risk, and shutdown complexity. Prefer message ownership or partitioned state when those better match the workload.
Capture only what the task needs. The most effective fix for an oversized non-Send closure is often to extract a small owned request before spawning, not to make the entire service graph thread-safe.
Async callbacks add a second hidden type
An ordinary closure returning an async block has a closure environment and, on each call, produces a future with its own captured state. The callback bound must describe both layers. For a simple generic API this often looks conceptually like F: Fn(A) -> Fut plus Fut: Future<Output = R> and any required Send or lifetime bounds.
Do not add 'static to both layers until the executor or storage boundary requires it. Borrowing futures can be valid in a scoped operation. Conversely, a spawned future generally must own what it needs. Cancellation drops the future; captured guards and partially completed external effects therefore need deliberate cancellation safety.
Rust also has async closure traits whose implementation depends on whether the generated future borrows from the closure environment. Their exact lending rules are subtle and version-sensitive; consult the Reference and verify against the declared MSRV before choosing them for a public API. The durable model remains two lifecycles: invoking the callable and polling the returned future.
Diagnose a callback that captures too much
Suppose a reconciliation service registers this long-lived callback:
let callback = move |posting: &Posting| {
self.metrics.increment("review");
self.database.lookup(posting.cents)
}
Perform a capture audit:
- List the actual dependencies: one bounded metric handle and one lookup capability.
- Decide which are borrowed, cloned handles, or owned values.
- Determine whether the registry truly outlives the service and whether it crosses threads.
- Extract those dependencies before creating the closure.
- Re-evaluate
FnversusFnMut; interior mutation in a metrics handle may still permitFn. - Define deregistration and shutdown so the callback does not retain the database unintentionally.
Then compare three repairs: a borrowing callback tied to a scoped registry, an owning closure capturing two narrow handles, and a named ReconciliationCallback type. Choose based on lifecycle and observability, not line count.
Callback review failures
- Adding
moveand'staticuntil a lifetime error disappears, without auditing what is retained. - Requiring
Fneven though the caller invokes only once, excluding valid ownership handoffs. - Accepting
FnOncefor a retry loop, then discovering the callback is unavailable after the first attempt. - Cloning a payload inside a repeated closure without accounting for allocation and duplicate effects.
- Capturing an entire
selfwhen two narrow handles suffice. - Using
Arc<Mutex<_>>as a universal route toSend + Sync. - Boxing a callback when a generic parameter or function pointer states the real contract.
- Returning different closures behind
impl Fnand assuming equal signatures imply equal types. - Ignoring that cancellation may drop an async callback’s future before completion.
- Keeping callback registries alive through reference cycles or missing deregistration.
Exercise: make capability and retention explicit
Start with a callback that captures a complete service, mutates an attempt counter, owns a settlement report, and is required as Fn + Send + Sync + 'static. Reduce it systematically:
- Separate the repeated predicate from the one-shot report handoff.
- Give the predicate an
FnMutbound if the counter is genuinely local state; otherwise move counting into an explicit metrics dependency. - Give the completion action
FnOnceand transfer the report without cloning. - Extract narrow owned handles before constructing any long-lived closure.
- Implement a generic immediate caller, a borrowed callback view, and a boxed heterogeneous queue.
- Preserve a rejected E0507 fixture for the false “call twice while moving one report” contract.
- Document invocation count, retry, cancellation, panic, removal, thread, and lifetime behavior.
The exercise is complete when every bound can be justified by a line of caller control flow or storage behavior.
Field checklist
- What exact places enter the environment?
- Are they borrowed, mutably borrowed, copied, or moved?
- Does a call read, mutate, or move out of each capture?
- Will the caller invoke zero, one, or many times?
- Does the callable need generic identity, a borrowed object, boxed storage, or a plain function pointer?
- Which scope proves borrowed captures remain valid?
- Which operation truly requires
Send,Sync, or'static? - Can cancellation skip or interrupt an effect?
- What resources stay alive until the callback is dropped?
- Would a named type make the boundary easier to review and operate?
Durable takeaways
A closure is not merely inline syntax for a function. It is a unique anonymous type carrying an environment. Capture mode establishes ownership and lifetime; use of captures establishes Fn, FnMut, or FnOnce; storage and execution establish erasure, allocation, and thread bounds. Read those layers separately, and callback diagnostics become architecture feedback rather than prompts to add move, clone, box, or 'static blindly.
This model prepares the iterator chapter: iterator adapters compose closure environments with borrowing, ownership, and lazy control flow, so their performance and lifetime behavior follow the same contracts.
Sources and version notes
- Rust Reference: closure types and capture precision
- Rust standard library:
Fn,FnMut, andFnOnce - Rust standard library: function pointers
- Capture, call-trait, returned closure, boxed callback, thread-bound, and E0507 examples were checked with Rust 1.97.0 and the crate’s Rust 1.85.0 MSRV on
x86_64-unknown-linux-gnu. Async closure trait details are version-sensitive and must be rechecked against a library’s declared MSRV before becoming a public compatibility promise.
Continue reading
Full table of contents