Closures
Closures are anonymous functions that capture variables from their surrounding scope. They are the glue that makes iterators, callbacks, and higher-order functions work in Rust. The syntax is concise, the performance is zero-cost, and the type system ensures closures interact correctly with ownership and borrowing.
Basic Syntax
fn main() {
// Closure with explicit types
let add = |x: i32, y: i32| -> i32 { x + y };
// Type inference — Rust figures it out from usage
let double = |x| x * 2;
// Single expression — no braces needed
let is_positive = |x: i32| x > 0;
// Multi-line closure
let describe = |x: i32| {
if x > 0 {
format!("{} is positive", x)
} else if x < 0 {
format!("{} is negative", x)
} else {
String::from("zero")
}
};
println!("{}", add(3, 4));
println!("{}", double(21));
println!("{}", is_positive(-5));
println!("{}", describe(42));
}
7
42
false
42 is positive
Closures look like |parameters| body. The vertical bars replace the parentheses of a function signature. Type annotations are optional when the compiler can infer them.
Capturing Variables
Closures capture variables from their enclosing scope. How they capture depends on how the variable is used:
fn main() {
let name = String::from("Alice");
let threshold = 10;
// Captures `name` by reference and `threshold` by copy
let greet = |greeting: &str| {
println!("{}, {}! (threshold: {})", greeting, name, threshold);
};
greet("Hello");
greet("Hi");
// name is still valid — it was borrowed, not moved
println!("Name is still: {}", name);
}
Hello, Alice! (threshold: 10)
Hi, Alice! (threshold: 10)
Name is still: Alice
The compiler captures by the least restrictive method possible:
- By immutable reference (
&T) — if the closure only reads the value - By mutable reference (
&mut T) — if the closure modifies the value - By value (move) — if the closure takes ownership
fn main() {
// Capture by immutable reference
let data = vec![1, 2, 3];
let print_data = || println!("{:?}", data);
print_data();
println!("Still here: {:?}", data);
// Capture by mutable reference
let mut count = 0;
let mut increment = || {
count += 1;
println!("Count: {}", count);
};
increment();
increment();
// cannot use count here while increment borrows it mutably
drop(increment); // release the borrow
println!("Final count: {}", count);
// Capture by value (move)
let name = String::from("Bob");
let consume = || {
let local = name; // moves name into the closure
println!("Consumed: {}", local);
};
consume();
// name is no longer valid — it was moved into the closure
}
[1, 2, 3]
Still here: [1, 2, 3]
Count: 1
Count: 2
Final count: 2
Consumed: Bob
Fn, FnMut, FnOnce: The Three Closure Traits
Every closure implements one or more of these traits, determined by how it captures and uses its environment:
// Fn: borrows captured variables immutably. Can be called multiple times.
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(f(x))
}
// FnMut: borrows captured variables mutably. Can be called multiple times.
fn apply_and_count<F: FnMut(i32) -> i32>(mut f: F, values: &[i32]) -> Vec<i32> {
values.iter().map(|&x| f(x)).collect()
}
// FnOnce: takes ownership of captured variables. Can only be called once.
fn consume<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
// Fn: only reads the multiplier
let multiplier = 3;
let triple = |x| x * multiplier;
println!("Triple 5 twice: {}", apply_twice(triple, 5));
// FnMut: modifies the counter
let mut calls = 0;
let counting_double = |x: i32| {
calls += 1;
x * 2
};
let results = apply_and_count(counting_double, &[1, 2, 3]);
println!("Results: {:?}, calls: {}", results, calls);
// FnOnce: consumes the string
let name = String::from("world");
let greeting = || format!("Hello, {}!", name);
// name is moved into the closure
println!("{}", consume(greeting));
// greeting cannot be called again — it was consumed by consume()
}
Triple 5 twice: 45
Results: [2, 4, 6], calls: 3
Hello, world!
The hierarchy: FnOnce is the most general (all closures implement it). FnMut refines it (can be called multiple times). Fn is the most restrictive (no mutation of captures).
move Closures
The move keyword forces a closure to take ownership of all captured variables, regardless of how they are used:
use std::thread;
fn main() {
let message = String::from("Hello from thread");
// Without move, this won't compile — the thread might outlive `message`
let handle = thread::spawn(move || {
println!("{}", message);
});
// message is no longer valid here — it was moved into the thread
handle.join().unwrap();
// move with Copy types: the value is copied, not moved
let x = 42;
let closure = move || println!("x = {}", x);
closure();
println!("x is still: {}", x); // x was copied, not moved
}
Hello from thread
x = 42
x is still: 42
move is essential for threads and async code where closures outlive the scope they were created in.
Closures as Function Arguments
Most iterator methods take closures. Here is how they work as function parameters:
fn filter_and_transform<F, G>(data: &[i32], predicate: F, transform: G) -> Vec<i32>
where
F: Fn(&i32) -> bool,
G: Fn(i32) -> i32,
{
data.iter()
.filter(|x| predicate(x))
.map(|&x| transform(x))
.collect()
}
fn apply_to_each<F: FnMut(&str)>(items: &[&str], mut action: F) {
for item in items {
action(item);
}
}
fn main() {
let numbers = vec![1, -2, 3, -4, 5, -6];
let result = filter_and_transform(
&numbers,
|&&x| x > 0,
|x| x * x,
);
println!("Positive squares: {:?}", result);
let mut log = Vec::new();
apply_to_each(&["hello", "world", "rust"], |s| {
log.push(s.to_uppercase());
});
println!("Log: {:?}", log);
}
Positive squares: [1, 9, 25]
Log: ["HELLO", "WORLD", "RUST"]
Use Fn when the closure only reads its captures. Use FnMut when it modifies them. Use FnOnce when it consumes them.
Returning Closures
Functions can return closures using impl Fn:
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn make_multiplier(factor: f64) -> impl Fn(f64) -> f64 {
move |x| x * factor
}
fn make_validator(min: i32, max: i32) -> impl Fn(i32) -> bool {
move |x| x >= min && x <= max
}
fn main() {
let add_five = make_adder(5);
let double = make_multiplier(2.0);
let is_valid_port = make_validator(1, 65535);
println!("10 + 5 = {}", add_five(10));
println!("3.14 * 2 = {}", double(3.14));
println!("8080 valid? {}", is_valid_port(8080));
println!("0 valid? {}", is_valid_port(0));
}
10 + 5 = 15
3.14 * 2 = 6.28
8080 valid? true
0 valid? false
impl Fn(i32) -> i32 means "some type that implements Fn(i32) -> i32." The actual closure type is anonymous — you cannot name it directly.
When Closures Help vs Hurt
Closures make code clearer when the logic is short and the intent is obvious:
fn main() {
let users = vec![
("Alice", 30, true),
("Bob", 25, false),
("Charlie", 35, true),
("Diana", 28, true),
];
// Clear: the closure's purpose is obvious
let active_seniors: Vec<_> = users.iter()
.filter(|(_, age, active)| *active && *age >= 30)
.map(|(name, _, _)| *name)
.collect();
println!("Active 30+: {:?}", active_seniors);
// Less clear: complex logic belongs in a named function
let mut sorted = users.clone();
sorted.sort_by(|a, b| {
// Sort by active status (active first), then by age descending
b.2.cmp(&a.2).then(b.1.cmp(&a.1))
});
println!("Sorted: {:?}", sorted);
}
Active 30+: ["Alice", "Charlie"]
Sorted: [("Charlie", 35, true), ("Alice", 30, true), ("Diana", 28, true), ("Bob", 25, false)]
If a closure exceeds 5-6 lines or contains complex logic, extract it into a named function. Named functions are easier to test, document, and understand.
Real-World Example: Retry with Configurable Strategy
use std::time::Duration;
use std::thread;
fn retry<F, T, E>(max_attempts: u32, delay: Duration, mut operation: F) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
E: std::fmt::Display,
{
let mut last_err = None;
for attempt in 1..=max_attempts {
match operation() {
Ok(value) => return Ok(value),
Err(e) => {
eprintln!("Attempt {}/{} failed: {}", attempt, max_attempts, e);
last_err = Some(e);
if attempt < max_attempts {
thread::sleep(delay);
}
}
}
}
Err(last_err.unwrap())
}
fn main() {
let mut call_count = 0;
let result = retry(3, Duration::from_millis(100), || {
call_count += 1;
if call_count < 3 {
Err(format!("service unavailable (attempt {})", call_count))
} else {
Ok("response data".to_string())
}
});
match result {
Ok(data) => println!("Success: {}", data),
Err(e) => println!("Failed: {}", e),
}
}
Attempt 1/3 failed: service unavailable (attempt 1)
Attempt 2/3 failed: service unavailable (attempt 2)
Success: response data
The retry function is generic over any closure that returns a Result. The caller provides both the operation and the retry configuration.
Common Pitfalls
- Capturing more than intended. A closure that captures
&selfborrows the entire struct. If you only need one field, borrow the field before the closure:let field = &self.field; let closure = || use(field);. - Forgetting
movefor thread closures. Closures passed tothread::spawnmust own their data. The compiler will tell you, but understand why: the thread may outlive the caller's scope. - Using
FnOncewhenFnMutwould work. If a function parameter only needsFnOnce, callers cannot reuse the closure. Use the least restrictive trait that works. - Over-nesting closures. Deeply nested closures are hard to read. Extract intermediate steps into variables or named functions.
- Not realizing closures have unique types. Two closures with the same signature have different types. You cannot put them in a
Vecwithout boxing:Vec<Box<dyn Fn(i32) -> i32>>. - Mutating captured variables without
muton the binding. If a closure modifies a captured variable, the variable must be declaredlet mutand the closure binding must also belet mut.
Key Takeaways
- Closures capture variables by reference, mutable reference, or value — automatically choosing the least restrictive option.
Fnreads captures,FnMutmodifies them,FnOnceconsumes them. Use the least restrictive trait in function signatures.moveforces ownership transfer into the closure. Essential for threads and async.- Closures are zero-cost. The compiler monomorphizes them and often inlines them entirely.
- Use closures for short, clear transformations. Extract complex logic into named functions.
- Every closure has a unique anonymous type. Use
impl Fnfor function returns ordyn Fn(boxed) for collections.