Closures, Capture, and the Fn Traits
Closures are unnamed function-like values that can capture surrounding state. They power iterator adapters, retry policies, tool handlers, validation rules, test fakes, routers, and async tasks.
In an AI harness, capture semantics can also become authority semantics. A closure that consumes a
one-shot capability is fundamentally different from a reusable read-only predicate. Rust expresses
that difference through Fn, FnMut, and FnOnce.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- define closures and understand how parameter/return types are inferred;
- predict whether a closure captures by shared borrow, mutable borrow, or move;
- explain the relationship among
Fn,FnMut, andFnOnce; - choose generic closure bounds, function pointers, or trait objects;
- use
movecorrectly for owned async/task boundaries; - avoid accidentally retaining large or secret values;
- model one-shot authority with consumption;
- debug common capture and borrow-checker errors;
- run a companion demonstrating all three call traits.
Dependencies and downstream use
Section titled “Dependencies and downstream use”This chapter depends on ownership, borrowing, iterators, and traits as a concept. The next chapters develop general traits and runtime-selected trait objects.
Closures sit at important control points:
Iterator::filter ── Fn predicateretry loop ── FnMut policy with attempt statetool execution ── FnOnce operation consuming capability/permitspawned task ── move closure owning task dataThe compiler infers the least restrictive capture mode and call trait supported by the closure body.
Closure syntax and inference
Section titled “Closure syntax and inference”let score = |text: &str| text.len();let value = score("evidence");Types can often be inferred:
let add_one = |value| value + 1;let two = add_one(1_u32);Once inferred, one closure value has one concrete parameter/return type. It is not a generic function that can later accept an unrelated type.
A closure has an anonymous compiler-generated type. Even two closures with identical text generally have different types. Generic function parameters allow each caller’s closure type to be monomorphized.
Capture modes
Section titled “Capture modes”A closure may capture an outer value:
let minimum_length = 8;let keep = |text: &str| text.len() >= minimum_length;Because the body only reads minimum_length, the closure can capture it by shared reference.
If the body changes outer state:
let mut calls = 0;let mut score = |text: &str| { calls += 1; text.len()};the closure needs a mutable capture.
If it moves a captured value out:
let request = String::from("run");let consume = || submit(request);the closure owns request and can be called only once.
The capture kind is based on body usage, not merely on whether move appears.
Fn, FnMut, and FnOnce
Section titled “Fn, FnMut, and FnOnce”These traits form a capability hierarchy.
Fn: callable through shared access
Section titled “Fn: callable through shared access”An Fn closure does not need to mutate or consume its captured environment:
fn select<F>(items: &[&str], predicate: F) -> Vec<&str>where F: Fn(&str) -> bool,It can be called repeatedly and potentially concurrently when its captured state also satisfies thread-safety requirements.
FnMut: callable through mutable access
Section titled “FnMut: callable through mutable access”FnMut may update captured state:
fn score_all<F>(items: &[&str], mut score: F) -> Vec<usize>where F: FnMut(&str) -> usize,The caller passes the closure by value, and the function owns a mutable local binding to invoke it. One call cannot overlap another through the same mutable closure reference.
FnOnce: callable by consuming itself
Section titled “FnOnce: callable by consuming itself”Every closure implements FnOnce; some can be called only once because the call consumes captured
state:
fn invoke_with_capability<F>( token: CapabilityToken, operation: F,) -> Stringwhere F: FnOnce(CapabilityToken) -> String,{ operation(token)}This is useful for finalizers, one-shot callbacks, transactions, permits, and authority that should not be reused.
The relationship
Section titled “The relationship”Conceptually:
Fn ──also usable as──▶ FnMut ──also usable as──▶ FnOnceAn Fn closure can satisfy a caller requiring FnMut or FnOnce. The reverse is not generally
true. Choose the weakest requirement your function needs. Requiring Fn when one sequential mutable
call would suffice rejects useful closures unnecessarily.
move controls capture ownership
Section titled “move controls capture ownership”A move closure takes ownership of captured values when possible:
let run_id = run_id.clone();let task = tokio::spawn(async move { process(run_id).await});move does not mean every captured operation becomes a deep copy. Moving a String transfers its
allocation; moving an Arc<T> transfers one reference-counted handle.
If the closure captures a Copy value, it copies it. If it captures a reference, it moves the
reference—not the referent. Therefore:
let borrowed: &str = &request_body;let closure = move || use_text(borrowed);still borrows request_body; it does not make the text 'static. Convert to String or Arc<str>
when independent lifetime is required.
Capture precision and retained memory
Section titled “Capture precision and retained memory”Modern Rust can capture individual fields rather than an entire struct in many cases:
let run_id = state.run_id.clone();let work = move || trace(run_id);Explicitly extracting small fields remains valuable in security/performance review. A closure stored for later can otherwise retain:
- a complete request body;
- large media buffers;
- credentials inside configuration;
- database pools and runtime handles;
- an
Arcthat prevents artifact eviction.
Treat a long-lived closure’s environment like a struct. Audit every captured field.
Generic closure parameters
Section titled “Generic closure parameters”For a function used with a known-at-compile-time callback:
fn verify_with<F>(candidate: &Candidate, verifier: F) -> boolwhere F: Fn(&Candidate) -> bool,{ verifier(candidate)}Benefits:
- static dispatch and inlining opportunities;
- no heap allocation required;
- caller-specific closure types;
- expressive capture.
Costs:
- code may be monomorphized for many closure types;
- public signatures can become complex;
- callbacks cannot easily be stored heterogeneously.
Use &F or &mut F when ownership of the callback should remain with the caller and repeated calls
span several helper functions. Use by-value F when the callee owns the callback for its operation.
Function pointers
Section titled “Function pointers”A non-capturing closure can coerce to a function pointer:
fn byte_length(value: &str) -> usize { value.len()}
let scorer: fn(&str) -> usize = byte_length;Function pointers are simple, copyable, and useful for C-compatible interfaces or fixed tables.
They cannot carry captured configuration. A generic Fn parameter accepts both ordinary functions
and capturing closures.
Trait objects for stored callbacks
Section titled “Trait objects for stored callbacks”When callbacks must be stored together or selected at runtime:
type Rule = Box<dyn Fn(&Candidate) -> Result<(), Violation> + Send + Sync>;This adds dynamic dispatch and usually a heap allocation. It also requires a clear lifetime:
Box<dyn Fn(&Candidate) -> bool + Send + Sync + 'static>The 'static bound means the callback environment contains no short-lived borrows. It can still be
dropped normally with the box.
Prefer a named trait over a complex closure trait object when the behavior has several methods, associated metadata, versions, or capability guarantees. Later provider and tool boundaries use named traits.
Closures and concurrency
Section titled “Closures and concurrency”Cross-thread closures may need:
Sendto move the environment to another thread;Syncwhen shared calls can occur from several threads;'staticwhen the task may outlive the current stack;Fnfor concurrent shared invocation, unless access is synchronized.
A closure capturing Rc<T> cannot be sent across threads. One capturing Arc<T> may be, if T
itself satisfies the required bounds.
Do not fix every error by wrapping mutable state in Arc<Mutex<_>>. Decide whether the state belongs
to one task, can be passed as messages, can be an immutable snapshot, or genuinely requires shared
coordination.
Side effects and retry semantics
Section titled “Side effects and retry semantics”A retry callback can be invoked more than once. Its type does not prove idempotency:
async fn retry<F, Fut, T, E>(mut operation: F) -> Result<T, E>where F: FnMut() -> Fut, Fut: Future<Output = Result<T, E>>,The API must define:
- maximum calls;
- which errors retry;
- whether each call has a new idempotency key;
- cancellation behavior;
- observation/tracing;
- backoff budget.
FnMut communicates repeated sequential calls with mutable environment, not that repeating an
external write is safe.
One-shot authority as consumption
Section titled “One-shot authority as consumption”Capabilities can be values:
struct RenderApproval { plan_hash: ContentHash, expires_at: Timestamp,}
fn render<F>(approval: RenderApproval, execute: F) -> RenderReceiptwhere F: FnOnce(RenderApproval) -> RenderReceipt,{ execute(approval)}The closure receives and consumes approval. Safe Rust prevents the same non-Clone approval value
from being passed twice.
This is defense in depth, not a complete authorization system. A caller might obtain two approvals, the process might restart, or an external operation might be repeated. Durable effect receipts and idempotency controls remain necessary.
Avoid deriving Clone for authority-bearing types without a documented reason.
Returning closures
Section titled “Returning closures”Return an opaque closure when callers only need to invoke it:
fn threshold(minimum: f32) -> impl Fn(&Candidate) -> bool { move |candidate| candidate.score >= minimum}The concrete closure type stays hidden. All return paths must produce the same concrete type. If runtime branches need different closure types, box a trait object or move the branch inside one closure.
Be explicit about lifetime if the returned closure borrows configuration. Often an owned copied
threshold or Arc<Config> makes the boundary simpler.
Runnable companion implementation
Section titled “Runnable companion implementation”Run:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-domain --example closures_and_capturecargo test --workspace --examplesExpected output:
used read_incident for 2 candidatesThe code is
crates/mosaic-domain/examples/closures_and_capture.rs.
It demonstrates:
- an
Fnpredicate borrowing an immutable threshold; - an
FnMutscorer incrementing an invocation counter; - an
FnOnceoperation consuming a non-Clonecapability token; - a test proving the mutable closure ran exactly once per input.
Failure investigation: closure retains a multi-gigabyte artifact
Section titled “Failure investigation: closure retains a multi-gigabyte artifact”Symptom: a run completes but memory remains high while a callback registry entry exists.
Likely cause: the stored move closure captured an Arc<Artifact> instead of a small artifact
ID.
Investigation:
- inspect the closure’s construction site;
- extract captures into named locals;
- inspect
Arc::strong_countonly as a debugging clue, not synchronization; - remove the registry entry and compare memory;
- replace the capture with ID/hash and fetch within a bounded scope;
- add retention metrics or a regression test around drop behavior.
Fix: capture only what invocation needs. Long-lived callback environments require the same review as cache entries.
Failure investigation: callback cannot be spawned
Section titled “Failure investigation: callback cannot be spawned”Symptom: compiler reports the future or closure is not Send or does not live long enough.
Procedure:
- identify every captured value;
- find the non-
Sendor borrowed member from the compiler note; - decide whether the work truly must cross a thread/task boundary;
- if yes, move owned thread-safe state such as
String/Arc<T>; - if no, keep it local or use structured scope;
- do not add unsafe
Sendimplementations.
The error usually reveals an architecture fact, not a missing incantation.
Common failure modes
Section titled “Common failure modes”Adding move and expecting a deep owned copy
Section titled “Adding move and expecting a deep owned copy”Moving a reference still captures a reference. Own the referent when required.
Requiring Fn unnecessarily
Section titled “Requiring Fn unnecessarily”If the function calls sequentially and permits state, FnMut is more flexible.
Hiding external effects in iterator closures
Section titled “Hiding external effects in iterator closures”Laziness can call them zero or multiple times. Make model/tool operations explicit and traced.
Capturing broad application state
Section titled “Capturing broad application state”Extract the minimum capability/configuration needed by the closure.
Cloning one-shot authority
Section titled “Cloning one-shot authority”Keep capability types non-Clone unless duplication is part of policy.
Assuming FnOnce means the external effect happens once globally
Section titled “Assuming FnOnce means the external effect happens once globally”It governs one Rust closure value. Durable idempotency remains necessary.
Security and performance implications
Section titled “Security and performance implications”Security:
- audit captured secrets and authorities;
- avoid
Debugoutput that exposes closure-adjacent state; - make one-shot capabilities non-
Clone; - bound callback invocation and retry counts;
- require
Send/Synconly after checking captured types; - never implement these auto traits unsafely merely to satisfy an executor.
Performance:
- generic closure calls can inline and avoid allocation;
- trait-object callbacks add indirection but are often negligible beside inference;
- monomorphizing many large generic call sites can increase binary size;
- captured
Arcs can delay large-resource release; - closures inside hot loops should not allocate per item unless measured acceptable.
Optimize the policy surface first. A dynamically dispatched model call is not slow because of one vtable lookup compared with network and inference latency.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What makes a closure
FnMutrather thanFn? - What action can make a closure only
FnOnce? - Does
moveclone captured data? - What does a
'staticcallback bound mean?
Implementation
Section titled “Implementation”- Return a threshold predicate with
impl Fn. - Build a bounded retry helper accepting
FnMut. - Create a non-
CloneToolApprovalconsumed by anFnOnceexecutor. - Store three validation rules as boxed
Fntrait objects with stable rule IDs. - Spawn a task that owns only a run ID, not the full run state.
Design
Section titled “Design”For each behavior, choose a function item, generic closure, boxed closure, or named trait:
- one local sort comparator;
- runtime-selected model provider;
- stored list of field validation rules;
- one-shot transaction finalizer;
- cross-thread progress observer;
- plugin with metadata and multiple operations.
Explain call count, mutation, capture, storage, dispatch, and thread requirements.
Failure lab
Section titled “Failure lab”Create and explain compiler failures for:
- calling an
FnOnceclosure twice; - mutating captured state through a function requiring
Fn; - spawning a closure that captures
Rc; - returning a closure borrowing a local value;
- moving a captured string and then using it outside.
Repair the semantic ownership instead of copying compiler suggestions blindly.
Solution guidance
Section titled “Solution guidance”The retry helper should accept a maximum attempt count and return the final typed error. Document whether attempt zero/one naming is used, and never retry non-idempotent effects without a separate idempotency contract.
Stored rules can use:
struct Rule { id: RuleId, check: Box<dyn Fn(&Candidate) -> Result<(), Violation> + Send + Sync>,}Keep rule output bounded and sort violations by rule ID.
For spawned work, extract run_id.clone() before async move. If the task needs more, capture an
explicit narrow struct rather than a broad AppState.
The plugin should use a named trait because it has identity, metadata, and multiple behaviors—not just one call signature.
Check your understanding
Section titled “Check your understanding”- Can an
Fnclosure be passed whereFnOnceis required? - Can an
FnOnceclosure always be passed whereFnis required? - Why might a copied integer still be captured by a
moveclosure? - What does
Arc<&str>own? - How can a callback retain memory after a run completes?
- Does consuming a capability prove a database effect was globally executed once?
- When does a named trait communicate more than a closure bound?
Completion checklist
Section titled “Completion checklist”- I can predict capture by shared borrow, mutable borrow, or move.
- I choose the least restrictive correct
Fnbound. - I understand what
movedoes to references and owners. - I audit long-lived closure environments.
- I model one-shot in-process authority with consumption where useful.
- I separately design durable idempotency.
- I do not hide expensive side effects inside surprising lazy closures.
- I ran the companion example and invocation-count test.