Using Closures Effectively
Practical patterns for passing closures to functions, storing them in structs, and designing flexible APIs in Rust
Rust closures unlock patterns that regular functions cannot match because closures carry state from their definition site. Knowing how to pass them around, store them, and choose the right trait bound turns them from a convenient shorthand into a design tool. This section focuses on the mechanics and decisions that make closures effective in real code — how to accept them in function signatures, how to hold onto them in data structures, and which trade-offs matter when you do.
Passing Closures to Functions
A function that accepts a closure receives a piece of behavior it can execute, not just data. The standard library uses this everywhere: Option::unwrap_or_else, Iterator::filter, thread::spawn. To write your own such function, you need to tell the compiler what kind of closure you expect, and you do that with trait bounds.
The three closure traits — Fn, FnMut, and FnOnce — form a hierarchy. A closure that implements Fn also implements FnMut and FnOnce. A closure that implements FnMut also implements FnOnce. The trait a closure gets depends on how it uses captured variables.
Generic Functions with Closure Bounds
The most common way to accept a closure is with a generic parameter constrained by one of the three traits. The compiler sees the exact closure type and can inline the call — the closure becomes a zero-cost abstraction.
/// Applies a transformation to a number and prints the result.
fn apply_twice<F>(mut f: F, value: i32)
where
F: FnMut(i32) -> i32,
{
let first = f(value);
let second = f(first);
println!("Applied twice: {second}");
}
fn main() {
let scale = 3;
apply_twice(|x| x * scale, 5); // prints "Applied twice: 45"
}
The closure |x| x * scale borrows scale immutably, so it implements Fn — which means it also satisfies the FnMut bound. The bound FnMut(i32) -> i32 signals that the closure can be called multiple times and may mutate its captured state (even if this particular one doesn't). The function takes f as mut because calling an FnMut closure requires mutable access to the closure itself.
What happens if the closure needs ownership of a captured value and can only run once? Then you accept FnOnce:
fn consume_and_print<F>(f: F)
where
F: FnOnce() -> String,
{
let message = f();
println!("{message}");
}
fn main() {
let greeting = String::from("hello");
consume_and_print(move || greeting); // closure takes ownership
// greeting no longer accessible here
}
FnOnce is the most permissive bound — every closure satisfies it. Use it when the function will call the closure at most once and doesn't care about repeated calls.
impl Trait in Argument Position
For simple cases, writing impl Fn(i32) -> i32 directly in the parameter list reads more like English:
fn execute(op: impl Fn(i32) -> i32, input: i32) -> i32 {
op(input)
}
This is syntactic sugar for the generic version with an invisible type parameter. It works only when you don't need to name the type elsewhere.
When to use each:
Use impl Fn for short signatures with a single closure parameter. Use the full generic with where when you have multiple constraints, need to name the type, or want to set a 'static bound for thread spawning.
Function Pointers vs Closures
A function pointer (fn(i32) -> i32) can be passed wherever a closure trait is expected, because Rust implements Fn, FnMut, and FnOnce for all function pointers. However, function pointers cannot capture state. If you only need stateless behavior, accepting fn pointers is more restrictive and may limit callers. Accepting a closure trait gives callers maximum flexibility.
Storing Closures in Structs
Storing a closure inside a struct lets you keep behavior attached to data — a processor that can transform values in different ways depending on how it was constructed, or a validator that runs an arbitrary check later. The struct then acts like a configurable function object.
Generic Field Approach (Compile-Time)
When the closure type is known at compile time and doesn't need to change, use a generic parameter on the struct:
struct Processor<F>
where
F: Fn(i32) -> i32,
{
operation: F,
}
impl<F> Processor<F>
where
F: Fn(i32) -> i32,
{
fn new(operation: F) -> Self {
Processor { operation }
}
fn run(&self, value: i32) -> i32 {
(self.operation)(value)
}
}
The parentheses around (self.operation)(value) are required. Without them, Rust would parse self.operation(value) as calling a method named operation — and structs can have both fields and methods with the same name. The extra parentheses disambiguate: "call the field as a function, not the method."
Invoking a closure field requires parentheses around the field access:
Writing self.operation(value) when operation is a field containing a closure will not compile. Rust sees it as a method call. Always write (self.operation)(value) to invoke a closure stored in a field.
The generic approach is zero-cost: the compiler monomorphizes the struct for each closure type, inlining the call. The trade-off is that Processor<|x| x*2> and Processor<|x| x+1> are different types — you cannot store both in the same Vec unless you erase the type.
Trait Object Approach (Runtime)
When you need to store different closures of varying types in the same collection or swap them at runtime, use Box<dyn Fn(i32) -> i32>:
struct DynamicProcessor {
operation: Box<dyn Fn(i32) -> i32>,
}
impl DynamicProcessor {
fn new(operation: Box<dyn Fn(i32) -> i32>) -> Self {
DynamicProcessor { operation }
}
fn run(&self, value: i32) -> i32 {
(self.operation)(value)
}
}
This adds a vtable lookup on every call and requires heap allocation, but it allows the struct to hold any closure that matches the signature. It's the right tool when the closure is chosen at runtime — for example, from a config file or a branch based on user input.
let doubler = Processor::new(|x| x * 2);
let result = doubler.run(21); // 42
The closure type is part of doubler's type. No heap allocation. Cannot swap the closure for a different one at runtime.
Trait objects and the `'static` bound:
When you return a Box<dyn Fn(...)> or store it in a struct that outlives the function, the compiler often imposes a 'static lifetime unless you explicitly annotate a shorter one. If the closure captures non-'static references, the code won't compile. For short-lived trait objects, you may need Box<dyn Fn(...) + 'a>.
Working with Option and Result
Closures shine when handling control flow with Option and Result. Methods like map, and_then, unwrap_or_else, and ok accept closures that transform or extract values only when needed.
The key advantage is laziness: the closure runs only if the variant calls for it. unwrap_or_else on Option<T> takes an FnOnce() -> T. If the Option is Some, the closure never runs — you avoid computing a default that isn't needed.
fn expensive_fallback() -> String {
// simulate a costly operation
"fallback_value".to_string()
}
let config: Option<String> = None;
let value = config.unwrap_or_else(|| expensive_fallback());
If config were Some(...), expensive_fallback would never execute. A plain unwrap_or(expensive_fallback()) would always evaluate the fallback, wasting work.
The same pattern applies to Result:
let result: Result<i32, &str> = Err("network timeout");
let message = result.unwrap_or_else(|err| format!("failed: {err}"));
Closures also enable chaining transformations without deep nesting:
let maybe_number: Option<&str> = Some("42");
let doubled = maybe_number
.and_then(|s| s.parse::<i32>().ok())
.map(|n| n * 2);
// doubled is Some(84)
Each closure only runs if the previous step produced a value. This replaces a multi-line match with a pipeline that reads top-to-bottom.
Customizing Behavior with Closures
Functions that accept a closure let the caller inject logic into a fixed algorithm. Sorting, building, and validating are classic examples.
Custom Sort Orders
Vec::sort_by takes a closure that compares two elements. The closure decides the order:
let mut scores = vec![("alice", 92), ("bob", 85), ("carol", 97)];
scores.sort_by(|a, b| b.1.cmp(&a.1)); // sort descending by score
The closure captures nothing, but if it needed to reference a local variable — say, a mapping of names to weights — it could borrow that variable and compute the comparison dynamically.
Builder Pattern with Validation
A struct can accumulate closures that act as validation rules, building a pipeline that runs when the object is finalized:
struct Request {
url: String,
headers: Vec<(String, String)>,
validators: Vec<Box<dyn Fn(&Request) -> Result<(), String>>>,
}
impl Request {
fn new(url: &str) -> Self {
Request {
url: url.to_string(),
headers: Vec::new(),
validators: Vec::new(),
}
}
fn validate<F>(mut self, rule: F) -> Self
where
F: Fn(&Request) -> Result<(), String> + 'static,
{
self.validators.push(Box::new(rule));
self
}
fn send(self) -> Result<(), Vec<String>> {
let errors: Vec<String> = self
.validators
.iter()
.filter_map(|v| v(&self).err())
.collect();
if errors.is_empty() {
// actual send logic
Ok(())
} else {
Err(errors)
}
}
}
Now the caller can compose validation logic without modifying the struct:
let req = Request::new("https://api.example.com/data")
.validate(|r| {
if !r.url.starts_with("https") {
Err("URL must use HTTPS".into())
} else {
Ok(())
}
})
.validate(|r| {
if r.headers.is_empty() {
Err("At least one header required".into())
} else {
Ok(())
}
});
match req.send() {
Ok(()) => println!("Request sent"),
Err(errs) => eprintln!("Validation failed: {:?}", errs),
}
Each closure captures nothing in this example, but a closure could borrow a local allowed_hosts set and validate against it. The 'static bound on the trait object is necessary because the validators are stored in a Vec that may outlive the function that added them.
Trait objects require `'static` unless annotated:
Without the + 'static bound, the compiler infers a lifetime tied to the closure's environment. If the closure borrows local variables, storing it in the struct becomes impossible because the struct would outlive the borrowed data. Adding 'static ensures the closure captures only owned data or 'static references.
Lazy Evaluation and Memoization
Closures enable deferring computation until a value is actually needed — and caching the result once it's computed. The classic example from the Rust book is a Cacher struct that stores a closure and its result.
use std::collections::HashMap;
struct Memoized<F, A, R>
where
F: Fn(A) -> R,
A: std::hash::Hash + Eq + Clone,
R: Clone,
{
calculation: F,
cache: HashMap<A, R>,
}
impl<F, A, R> Memoized<F, A, R>
where
F: Fn(A) -> R,
A: std::hash::Hash + Eq + Clone,
R: Clone,
{
fn new(calculation: F) -> Self {
Memoized {
calculation,
cache: HashMap::new(),
}
}
fn get(&mut self, arg: A) -> R {
if let Some(result) = self.cache.get(&arg) {
return result.clone();
}
let result = (self.calculation)(arg.clone());
self.cache.insert(arg, result.clone());
result
}
}
The get method checks the cache, runs the closure only on a miss, and stores the result. The Clone bounds let the caller receive a value without consuming the cached entry. This pattern pays off when the closure performs heavy work — parsing, network fetches, cryptographic operations — and the same input appears repeatedly.
fn main() {
let mut expensive = Memoized::new(|n: u64| {
println!("Computing fib({n})...");
fib(n)
});
println!("{}", expensive.get(20)); // computes
println!("{}", expensive.get(20)); // cached, no print
}
fn fib(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
The closure is Fn, so it borrows nothing (except the implicit fib function which is a static function pointer). If the closure needed mutable state — for example, a counter tracking how many times it was called — you would need FnMut and the struct's get method would require &mut self as it already does.
Choosing the Right Closure Trait
A common mistake is over-constraining a function that accepts a closure. If the function only calls the closure once and doesn't need to call it again, FnOnce is sufficient and gives callers the most flexibility. If the function calls the closure multiple times and the closure doesn't need to mutate its captures, Fn is the most restrictive but communicates that the closure won't be mutated. If the closure needs to update state between calls, FnMut is the only option.
| Trait | Can call multiple times | Can mutate captures | Example use case |
|---|---|---|---|
FnOnce | At most once | Yes, consumes captures | Option::unwrap_or_else |
FnMut | Yes | Yes | Iterator::for_each |
Fn | Yes | No (immutable borrow) | Iterator::filter |
When writing a function that accepts a closure, start with the least restrictive bound that still satisfies the function's needs. If you later discover you need to call the closure multiple times, the compiler will tell you and you can tighten the bound.
Moving out of a captured value makes the closure `FnOnce`:
A closure that moves a captured value out of itself — for example, returning a String it owns — implements only FnOnce. After the first call, the value is gone, so the closure cannot be called again. If you try to call it twice, the compiler will stop you with a clear error. This is why thread::spawn closures often use move to take ownership of data, but they are only called once by the thread.
Common Pitfalls
Type Inference Locking
A closure's types are inferred from the first usage. If you define a closure and call it with a String first, that closure is now locked to String. Passing an integer later causes a type mismatch.
let identity = |x| x;
let s = identity(String::from("hello")); // `identity` inferred as Fn(String) -> String
let n = identity(42); // error: expected String, found integer
The fix: if you need the same closure to work on multiple types, make it generic by writing a function instead, or use trait objects if the types share a common trait.
Borrow Checker Conflicts with Mutable Closures
A closure that mutably borrows a variable prevents any other use of that variable while the closure lives. This includes trying to use the variable after the closure is defined but before it's dropped.
let mut count = 0;
let mut inc = || count += 1;
count += 1; // error: cannot assign to `count` because it is borrowed by `inc`
inc();
The mutable borrow from the closure exists as long as inc is in scope. To regain access, drop the closure or restructure the code so the closure is called and released before further use.
move Closures and FnOnce
Adding move forces the closure to take ownership of captured values, but it does not automatically make the closure FnOnce. A move closure that only uses its owned values immutably — for example, printing them — implements Fn. It becomes FnOnce only when it moves captured values out of itself during a call.
let text = String::from("hello");
let print = move || println!("{text}"); // implements Fn, can be called multiple times
print();
print();
The confusion arises when people assume move always means "call once". It means "take ownership", not "consume on call".
Forgetting Parentheses on Struct Field Calls
As shown earlier, calling a closure stored in a struct field requires (self.field)(args). Missing the outer parentheses leads to a compiler error that mentions a method not found or mismatched types. This trips up developers who are used to calling methods directly.
Summary
Using closures effectively means matching the closure trait to the call pattern, choosing between generics and trait objects based on whether the closure type needs to be erased, and leveraging the laziness that closures provide. The standard library's Option and Result combinators, iterator adapters, and thread spawning all depend on this flexibility. When you design your own APIs to accept closures, you give callers the power to inject behavior without forcing them into a rigid function signature.