Skip to content

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.

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, and FnOnce;
  • choose generic closure bounds, function pointers, or trait objects;
  • use move correctly 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.

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 predicate
retry loop ── FnMut policy with attempt state
tool execution ── FnOnce operation consuming capability/permit
spawned task ── move closure owning task data

The compiler infers the least restrictive capture mode and call trait supported by the closure body.

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.

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.

These traits form a capability hierarchy.

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 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.

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,
) -> String
where
F: FnOnce(CapabilityToken) -> String,
{
operation(token)
}

This is useful for finalizers, one-shot callbacks, transactions, permits, and authority that should not be reused.

Conceptually:

Fn ──also usable as──▶ FnMut ──also usable as──▶ FnOnce

An 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.

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.

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 Arc that prevents artifact eviction.

Treat a long-lived closure’s environment like a struct. Audit every captured field.

For a function used with a known-at-compile-time callback:

fn verify_with<F>(candidate: &Candidate, verifier: F) -> bool
where
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.

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.

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.

Cross-thread closures may need:

  • Send to move the environment to another thread;
  • Sync when shared calls can occur from several threads;
  • 'static when the task may outlive the current stack;
  • Fn for 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.

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.

Capabilities can be values:

struct RenderApproval {
plan_hash: ContentHash,
expires_at: Timestamp,
}
fn render<F>(approval: RenderApproval, execute: F) -> RenderReceipt
where
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.

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.

Run:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-domain --example closures_and_capture
cargo test --workspace --examples

Expected output:

used read_incident for 2 candidates

The code is crates/mosaic-domain/examples/closures_and_capture.rs.

It demonstrates:

  • an Fn predicate borrowing an immutable threshold;
  • an FnMut scorer incrementing an invocation counter;
  • an FnOnce operation consuming a non-Clone capability 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:

  1. inspect the closure’s construction site;
  2. extract captures into named locals;
  3. inspect Arc::strong_count only as a debugging clue, not synchronization;
  4. remove the registry entry and compare memory;
  5. replace the capture with ID/hash and fetch within a bounded scope;
  6. 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:

  1. identify every captured value;
  2. find the non-Send or borrowed member from the compiler note;
  3. decide whether the work truly must cross a thread/task boundary;
  4. if yes, move owned thread-safe state such as String/Arc<T>;
  5. if no, keep it local or use structured scope;
  6. do not add unsafe Send implementations.

The error usually reveals an architecture fact, not a missing incantation.

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.

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.

Extract the minimum capability/configuration needed by the closure.

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:

  • audit captured secrets and authorities;
  • avoid Debug output that exposes closure-adjacent state;
  • make one-shot capabilities non-Clone;
  • bound callback invocation and retry counts;
  • require Send/Sync only 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.

  1. What makes a closure FnMut rather than Fn?
  2. What action can make a closure only FnOnce?
  3. Does move clone captured data?
  4. What does a 'static callback bound mean?
  1. Return a threshold predicate with impl Fn.
  2. Build a bounded retry helper accepting FnMut.
  3. Create a non-Clone ToolApproval consumed by an FnOnce executor.
  4. Store three validation rules as boxed Fn trait objects with stable rule IDs.
  5. Spawn a task that owns only a run ID, not the full run state.

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.

Create and explain compiler failures for:

  • calling an FnOnce closure 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.

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.

  • Can an Fn closure be passed where FnOnce is required?
  • Can an FnOnce closure always be passed where Fn is required?
  • Why might a copied integer still be captured by a move closure?
  • 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?
  • I can predict capture by shared borrow, mutable borrow, or move.
  • I choose the least restrictive correct Fn bound.
  • I understand what move does 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.