Chapter 12: Understanding Closures in Rust
Closures, sometimes called lambda expressions, are anonymous functions that can capture variables from their defining scope. This allows passing small units of behavior without the boilerplate often required in languages like C, such as using function pointers paired with manually managed context data (e.g., via void*).
Typical use cases include:
- Transforming or filtering iterators (
map,filter). - Defining callbacks for asynchronous or event-driven code.
- Supplying custom comparison predicates to sorting algorithms (
sort_by_key). - Deferring work until a value is actually needed (
unwrap_or_else). - Moving data and associated logic into another thread (
thread::spawn).
This chapter explains what closures are, how they capture their environment, and how Rust’s ownership and borrowing rules apply through the Fn, FnMut, and FnOnce traits. We will compare closures to functions and explore common use cases, including performance considerations relevant to C programmers.
12.1 Defining and Using Closures
A closure is essentially a function you can define inline, without a name, which automatically “closes over” or captures variables from its surrounding environment. A closure definition begins with vertical pipes (|...|) enclosing the parameters and can appear anywhere an expression is valid. Because it is an expression, you can store it in a variable, return it from a function, or pass it to another function—just like any other value. Closures assigned to variables or passed as parameters are called similar to ordinary functions, using the standard function call syntax () to enclose the potentially empty parameter list.
Key Characteristics:
- Anonymous: Closures don’t require a name, though they can be assigned to variables.
- Environment Capture: They can access variables from the scope where they are created.
- Concise Syntax: Parameter and return types can often be inferred.
12.1.1 Syntax: Closures vs. Functions
While similar, closures have a more flexible syntax than named functions.
Named Function Syntax:
#![allow(unused)]
fn main() {
fn add(x: i32, y: i32) -> i32 {
x + y
}
}
Closure Syntax:
#![allow(unused)]
fn main() {
let add = |x: i32, y: i32| -> i32 {
x + y
};
// Called like a function: add(5, 3)
}
If the closure body is a single expression, the surrounding curly braces {} are optional:
fn main() {
let square = |x: i64| x * x; // Braces omitted
println!("Square of {}: {}", 7, square(7)); // Output: Square of 7: 49
}
A closure taking no arguments uses empty pipes || as the syntax element identifying it as a closure with zero parameters:
fn main() {
let message = "Hello!";
let print_message = || println!("{}", message); // Captures 'message'
print_message(); // Output: Hello!
}
Parameter and return types can often be omitted if the compiler can infer them:
fn main() {
let add_one = |x| x + 1; // Types inferred (likely i32 -> i32 here)
let result = add_one(5);
println!("Result: {}", result); // Output: Result: 6
}
Key Differences Summarized:
| Aspect | Function | Closure |
|---|---|---|
| Name | Mandatory (fn my_func(...)) | Optional (can assign to let my_closure = ...) |
| Parameter / Return Types | Must be explicit | Inferred when possible |
| Environment Capture | Not allowed | Automatic by shared reference, exclusive reference, or move |
| Implementation Details | Standalone code item | A struct holding captured data + code logic |
| Associated Traits | Can implement Fn* traits if sig matches | Automatically implements one or more Fn* traits |
12.1.2 Environment Capture
Closures can use variables defined in their surrounding scope. Rust determines how to capture based on how the variable is used inside the closure body, choosing the weakest (least restrictive) mode necessary. These modes correspond to the Rust borrowing rules:
- Shared Borrow (
&T): If the closure only reads a variable. - Exclusive Borrow (
&mut T): If the closure modifies a variable. - Move (ownership transfer): If the closure consumes a variable (e.g.,
drops it).
fn main() {
let factor = 2; // Captured by shared reference (&factor)
let mut count = 0; // Captured by exclusive reference (&mut count)
let data = vec![1, 2]; // Moved (ownership of data transferred)
// This closure only reads `factor`. It takes a shared borrow.
let multiply_by_factor = |x| x * factor;
// This closure modifies `count`. It takes an exclusive borrow.
let mut increment_count = || {
count += 1;
println!("Count: {}", count);
};
// This closure consumes `data`. It takes ownership.
let consume_data = || {
println!("Data length: {}", data.len());
drop(data);
};
println!("Result: {}", multiply_by_factor(10)); // Output: Result: 20
increment_count(); // Output: Count: 1
increment_count(); // Output: Count: 2
consume_data(); // Output: Data length: 2
// consume_data();//Error: cannot call FnOnce closure twice (ownership was transf.)
// println!("{:?}", data); // Error: data was moved
// Borrowing rules apply: While 'increment_count' holds an exclusive borrow
// of 'count', 'count' cannot be accessed elsewhere.
// The borrow ends when 'increment_count' is no longer in use (due to NLL).
println!("Final factor: {}", factor); // OK: factor was only sharedly borrowed
println!("Final count: {}", count); // OK: exclusive borrow ended
}
Closures capture only the data they actually need. If a closure uses a field of a struct, only that field might be captured, especially with the move keyword (see Section 12.5.2). Standard borrowing rules apply: if a closure captures a variable exclusively, the original variable cannot be accessed in the enclosing scope while the closure holds the exclusive borrow.
12.1.3 Closures are First-Class Citizens
Like functions, closures are first-class values in Rust: they can be assigned to variables, passed as arguments, returned from functions, and stored in data structures. This includes passing closures to other functions, like iterator adapters. Sometimes, an intermediate closure is needed for adaptation if the closure’s signature doesn’t match what the function expects.
Example 1: Using an Adapter Closure
If you have an existing closure or want to define one with a specific signature, you might need an adapter when passing it.
fn main() {
// Define a closure that takes i32
let square = |x: i32| x * x;
println!("Square of 5: {}", square(5)); // Output: Square of 5: 25
// Pass it to an iterator adapter.
// The `map` adapter on numbers.iter() needs a closure compatible with &i32.
// Since `square` expects `i32`, we define a new, inline closure `|&x| square(x)`.
// This adapter closure takes the `&i32` (a shared reference), uses the `&x`
// pattern to get the inner `i32` value (by dereferencing), and then calls the
// captured `square` closure with that value.
let numbers = vec![1, 2, 3];
let squares: Vec<_> = numbers.iter().map(|&x| square(x)).collect();
println!("Squares (via adapter): {:?}", squares);
// Output: Squares (via adapter): [1, 4, 9]
}
Example 2: Direct Signature Matching
Alternatively, if you know the signature required by the function you’re passing the closure to (in this case, map on an iterator yielding &i32), you can define the closure to accept that type directly. This avoids the need for an intermediate adapter closure:
fn main() {
// Define the closure to accept the shared reference type directly.
// Note: We need to dereference `x_ref` inside the closure body.
let sqr_ref = |x_ref: &i32| (*x_ref) * (*x_ref);
let numbers = vec![1, 2, 3];
// Now 'sqr_ref' can be passed directly to map without an adapter.
let squares: Vec<_> = numbers.iter().map(sqr_ref).collect();
println!("Squares (direct): {:?}", squares); // Output: Squares (direct): [1, 4, 9]
}
Both approaches achieve the same result. Defining the closure with the expected signature, as in the second example, is often more direct when feasible. The first example demonstrates how closures can be adapted when needed, highlighting their flexibility.
12.1.4 Comparison with C and C++
In C, simulating closures requires function pointers plus a void* context, demanding manual state management and lacking type safety. C++ lambdas ([capture](params){body}) are syntactically similar to Rust closures but rely on C++’s memory rules. Rust closures integrate directly with the ownership and borrowing system, ensuring memory safety at compile time.
12.2 Closure Traits: FnOnce, FnMut, and Fn
How a closure interacts with its captured environment determines which of the three closure traits it implements: FnOnce, FnMut, and Fn. These traits dictate whether the closure consumes (takes ownership of), mutates (takes an exclusive borrow of), or only reads (takes a shared borrow of) its environment.
Implementation Hierarchy (from most restrictive to least restrictive on capture, or from least to most callable):
The traits form an implementation hierarchy based on the closure’s capabilities:
FnOnce: This is the most permissive trait regarding what it does with captured data, but the least permissive regarding how many times it can be called. A closure implementingFnOncecan be called at least once, potentially consuming (moving) its captured environment in the process. All closures implicitly implementFnOnce.FnMut: Closures implementingFnMutcan be called multiple times and can mutate their captured environment through exclusive borrows. They do not consume the environment. AllFnclosures also implementFnMut.Fn: This is the most restrictive trait regarding what it does with captured data, but the most permissive regarding how many times it can be called. Closures implementingFncan be called multiple times and only require shared access (or no access) to their environment. They take shared borrows of captured data.
This means Fn implies FnMut, and FnMut implies FnOnce. A closure implementing Fn can be used anywhere an FnMut or FnOnce is expected; an FnMut can be used where an FnOnce is expected.
The compiler automatically determines the most specific trait(s) (Fn, FnMut, or just FnOnce) that a closure implements based on how its body interacts with captured variables.
Usage as Trait Bounds:
Functions accepting closures use these traits as bounds in their generic signatures (e.g., <F: FnMut(i32) -> i32>). When used this way, the hierarchy relates to the permissiveness of the bound – what kinds of closures the function accepts:
F: FnOnce(...): This is the most permissive (least restrictive) bound. It accepts any closure matching the signature (Fn,FnMut, orFnOnce), as it only requires the closure to be callable at least once.F: FnMut(...): This bound is more restrictive. It accepts closures implementingFnMutorFn(sinceFnimpliesFnMut), requiring the closure to be callable multiple times, potentially taking exclusive borrows of its environment. It rejects closures that only implementFnOnce(i.e., consuming closures).F: Fn(...): This is the most restrictive bound. It only accepts closures implementingFn, requiring that the closure can be called multiple times without mutation. It takes shared borrows of its environment. It rejects closures that only implementFnMutorFnOnce.
Choosing the right bound depends on how the function intends to use the closure: call once (FnOnce), call multiple times with exclusive access (FnMut), or call multiple times with shared access (Fn).
Capture Examples:
-
Shared Borrow (
Fn): The closure only reads captured data. It takes a shared borrow ofmessage.fn main() { let message = String::from("Hello"); // Captures 'message' by shared reference. Implements Fn, FnMut, FnOnce. let print_message = || println!("{}", message); print_message(); print_message(); // Can call multiple times. println!("Original message still available: {}", message); // Still valid. } -
Exclusive Borrow (
FnMut): The closure modifies captured data. It takes an exclusive borrow ofcount.fn main() { let mut count = 0; // Captures 'count' by exclusive ref.. Implements FnMut, FnOnce (but not Fn). let mut increment = || { count += 1; println!("Count is now: {}", count); }; increment(); // count becomes 1 increment(); // count becomes 2 // The exclusive borrow ends when 'increment' is no longer used. println!("Final count: {}", count); // Can access count again. } -
Move (
FnOnce): The closure takes ownership of captured data.fn main() { let data = vec![1, 2, 3]; // 'drop(data)' consumes data, so closure must take ownership. // Implements FnOnce only. let consume_data = || { println!("Data length: {}", data.len()); drop(data); // Moves ownership of 'data' into drop. }; consume_data(); // consume_data(); // Error: cannot call FnOnce closure twice (ownership was transf.). // println!("{:?}", data); // Error: 'data' was moved. }
12.2.1 The move Keyword
Use move before the parameter list (move || ...) to force a closure to take ownership of all captured variables. This is vital when a closure must outlive its creation scope, like in threads, ensuring it owns its data rather than holding potentially dangling references.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// 'move' forces the closure to take ownership of 'data'.
let handle = thread::spawn(move || {
// 'data' is owned by this closure now.
println!("Data in thread (length {}): {:?}", data.len(), data);
// 'data' is dropped when the closure finishes.
});
// println!("{:?}", data); // Error: 'data' was moved.
handle.join().unwrap();
}
12.2.2 Closures as Function Parameters
Functions accepting closures use generic parameters with trait bounds (Fn, FnMut, FnOnce) to specify requirements.
// Accepts any closure that takes an i32, returns an i32,
// and can be called at least once.
fn apply<F>(value: i32, op: F) -> i32
where
F: FnOnce(i32) -> i32, // Most general bound that allows calling once
{
op(value)
}
// Accepts closures that can be called multiple times taking shared borrows.
fn apply_repeatedly<F>(value: i32, op: F) -> i32
where
F: Fn(i32) -> i32, // Requires only shared borrow
{
op(op(value)) // Call 'op' twice
}
fn main() {
let double = |x| x * 2; // Implements Fn, FnMut, FnOnce
println!("Apply once: {}", apply(5, double)); // Output: Apply once: 10
println!("Apply twice: {}", apply_repeatedly(5, double)); // Outp: Apply twice: 20
let data = vec![1];
let consume_and_add = |x| { // Implements FnOnce only
drop(data);
x + 1
};
println!("Apply consuming closure: {}", apply(5, consume_and_add)); // Output: 6
// apply_repeatedly(5, consume_and_add);
// Error: 'Fn' bound not met (requires shared access, but this closure consumes)
}
Choose the most restrictive bound needed: FnOnce if called once (or consumes captured data), FnMut if called multiple times with exclusive access (mutates captured data), Fn if called multiple times with shared access (reads captured data).
12.2.3 Function Pointers vs. Closures
Regular functions (fn name(...)) implicitly implement Fn* traits if their signature matches. They can be passed where closures are expected, but cannot capture environment variables.
fn add_one(x: i32) -> i32 {
x + 1
}
fn apply<F>(value: i32, op: F) -> i32
where
F: FnOnce(i32) -> i32,
{
op(value)
}
fn main() {
let result = apply(10, add_one); // Pass the function 'add_one'
println!("Result: {}", result); // Output: Result: 11
}
12.3 Common Use Cases for Closures
Closures excel at encapsulating behavior concisely.
12.3.1 Iterators
Used heavily with adapters like map, filter, fold:
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let evens: Vec<_> = numbers.iter()
.filter(|&&x| x % 2 == 0) // Closure predicate
.collect();
println!("Evens: {:?}", evens); // Output: Evens: [2, 4, 6]
let squares: Vec<_> = numbers.iter()
.map(|&x| x * x) // Closure transformation: takes a shared ref. &i32, deref. to i32
.collect();
println!("Squares: {:?}", squares); // Output: Squares: [1, 4, 9, 16, 25, 36]
}
12.3.2 Custom Sorting
sort_by and sort_by_key use closures for custom logic:
#[derive(Debug)] struct Person { name: String, age: u32 }
fn main() {
let mut people = vec![
Person { name: "Alice".to_string(), age: 30 },
Person { name: "Bob".to_string(), age: 25 },
Person { name: "Charlie".to_string(), age: 35 },
];
// Sort by age using 'sort_by_key'
people.sort_by_key(|p| p.age); // Closure extracts the key (takes shared borrow of p)
println!("Sorted by age: {:?}", people);
// Sort by name length using 'sort_by'
people.sort_by(|a, b| a.name.len().cmp(&b.name.len()));
// Closure compares elements (takes shared borrows of a and b)
println!("Sorted by name length: {:?}", people);
}
12.3.3 Lazy Initialization
Option::unwrap_or_else, Result::unwrap_or_else compute defaults lazily:
fn main() {
let config_path: Option<String> = None;
let path = config_path.unwrap_or_else(|| {
println!("Computing default path..."); // Runs only if None
String::from("/etc/default.conf")
});
println!("Using path: {}", path);
// Output: Computing default path...
// Output: Using path: /etc/default.conf
}
12.3.4 Concurrency and Asynchronous Operations
Essential for passing code (often with captured state via move) to threads or async tasks.
12.4 Performance Considerations
Rust closures provide strong performance characteristics:
- No Hidden Heap Allocations: Closure objects (the implicit struct holding captured data) typically live on the stack if their size is known at compile time. They are not automatically heap-allocated unless explicitly placed in a
Boxor other heap-based container. - Zero-Cost Abstraction (Generics): When closures are passed using generics (
impl Fn...), the compiler performs monomorphization, generating specialized code for each closure type. This allows inlining the closure body, resulting in performance equivalent to a direct function call. There is usually no runtime overhead. - Dynamic Dispatch (
dyn Fn...): Using trait objects (Box<dyn Fn()>,&dyn FnMut(), etc.) allows storing different closure types together but introduces:- A small runtime cost for vtable lookup (like C++ virtual functions).
- Heap allocation if using
Box<dyn Fn...>. This offers flexibility at the expense of some performance.
For performance-critical code, prefer generics (impl Fn...) over trait objects (dyn Fn...) to leverage static dispatch and inlining.
12.5 Advanced Topics
Finally, let’s briefly touch upon a few more advanced aspects of using closures.
12.5.1 Returning Closures
Since each closure has a unique, unnameable type, functions must return them opaquely:
-
impl Trait: Preferred. Returns an opaque type implementing the trait(s). Enables static dispatch.#![allow(unused)] fn main() { fn make_adder(a: i32) -> impl Fn(i32) -> i32 { move |b| a + b // Returns a specific, unnamed closure type } } -
Box<dyn Trait>: Returns a trait object on the heap. Requires heap allocation and dynamic dispatch, but allows returning different closure types.#![allow(unused)] fn main() { fn make_adder_boxed(a: i32) -> Box<dyn Fn(i32) -> i32> { Box::new(move |b| a + b) } }
12.5.2 Disjoint Capture in Closures (Rust 2021+)
Starting with the Rust 2021 Edition, closures capture struct fields more precisely using a feature called disjoint capture. Instead of capturing the entire struct variable, a closure now typically captures only the specific fields it actually uses.
When a closure uses a field whose type is not Copy (like String), disjoint capture means only that specific field is moved into the closure, transferring its ownership.
The primary effect is that the specific moved field becomes temporarily inaccessible via the original variable. While this field is “moved out”, operations requiring the whole struct to be valid (like moving it or using default Debug formatting) are also temporarily disallowed.
However, Rust tracks the validity of each field individually. Since other fields were not captured, they remain accessible:
- You can read (copy) remaining
Copyfields (likeu32). - You can take a shared borrow of remaining non-
Copyfields (likeString). - If the struct variable is mutable, you can assign new values to these other fields, or even re-assign a value to the originally moved field, making the struct whole and fully usable again.
#[derive(Debug)] // For final print
struct Settings {
mode: String, // Not Copy
api_key: String, // Not Copy
retries: u32, // Copy
}
fn main() {
let mut settings = Settings { // Must be mutable for re-assignment
mode: "fast".to_string(),
api_key: "ABC-123".to_string(),
retries: 3
};
// Closure moves settings.mode due to disjoint capture (Rust 2021+)
let mode_closure = move || {
println!("Mode is: {}", settings.mode);
};
mode_closure(); // settings.mode is now moved out
// Other fields remain accessible:
println!("API Key: {}", settings.api_key); // OK (Shared borrow)
println!("Retries: {}", settings.retries); // OK (Copy)
// Cannot access moved field or use struct as whole yet:
// println!("{}", settings.mode); // Error: use of moved value
// println!("{:?}", settings); // Error: requires all fields
// Can re-assign the moved field, making the struct whole again:
settings.mode = "slow".to_string();
// Now all fields and the struct are fully usable:
println!("Mode after re-assignment: {}", settings.mode); // OK
println!("Full settings: {:?}", settings); // OK
}
Disjoint capture makes closures more ergonomic and efficient, allowing finer-grained ownership transfer from structs. (Prior to the Rust 2021 edition, move closures would capture the entire settings struct if they used even one field like settings.mode, preventing subsequent access like println!("API Key: {}", settings.api_key);.)
12.6 Summary
Closures (or lambda expressions) in Rust are anonymous functions that capture variables from their environment. They enable concise, expressive code for passing behavior.
- Syntax:
|params| -> ReturnType { body }, types often inferred. Braces optional for single expressions. Closures assigned to variables or passed as arguments are called using the standard()syntax. - Capture: Automatically capture variables by shared reference (
Fn), exclusive reference (FnMut), or by taking ownership (FnOnce), based on usage.movekeyword forces ownership transfer. Standard borrow rules apply. - Traits:
Fn,FnMut,FnOncetraits define closure capabilities, used as bounds in functions. They represent shared access, exclusive access, and consumption, respectively. - First-Class: Can be stored, passed, and returned like any value.
- Comparison: Safer, more ergonomic alternative to C’s function pointer +
void*context. - Performance: Usually stack-allocated. Zero-cost abstraction via generics (
impl Fn...). Dynamic dispatch (dyn Fn...) incurs overhead.
Closures are fundamental to idiomatic Rust, powering iterators, concurrency, and customizable logic while upholding Rust’s safety and performance goals.