Closures in Rust
A closure is an anonymous function that can capture variables from the scope in which it is defined. Unlike regular functions, closures remember the environment around them — they "close over" surrounding variables, which is where the name comes from.
Closures are used everywhere in Rust: with iterators, threads, error handling, and callbacks. Understanding them unlocks a large and expressive part of the language.
Basic Closure Syntax
Closures use pipes | to delimit parameters instead of parentheses, and the body
can be a single expression or a block.
fn main() {
// Simplest closure — no parameters, no return value
let greet = || println!("Hello from a closure!");
greet();
// One parameter, inferred types
let double = |x| x * 2;
println!("double 5 = {}", double(5));
// Explicit types and block body
let add = |x: i32, y: i32| -> i32 {
let sum = x + y;
sum
};
println!("3 + 4 = {}", add(3, 4));
}Hello from a closure! double 5 = 10 3 + 4 = 7
Closures Capture Their Environment
The defining trait of a closure is that it can use variables from the surrounding scope. A regular function cannot do this — it can only work with its own parameters and items in scope at the module level.
fn main() {
let base = 10;
// Closure captures 'base' from the enclosing scope
let add_base = |x| x + base;
println!("{}", add_base(5)); // 15
println!("{}", add_base(20)); // 30
// 'base' is still usable here because it was only borrowed
println!("base is still: {}", base);
}15 30 base is still: 10
How Closures Capture: Three Modes
Rust captures variables in the most permissive way the closure's body allows. The three capture modes, from least to most ownership transfer, are:
Immutable borrow (
&T) — the closure reads the variable but does not modify it. The original variable remains accessible to the rest of the function.Mutable borrow (
&mut T) — the closure modifies the variable. No other access to the variable is allowed while the closure exists.Move / ownership (
T) — the closure takes ownership of the variable. The original binding is no longer accessible after the closure is created.
fn main() {
// Immutable borrow — closure only reads 'greeting'
let greeting = String::from("Hello");
let print_greeting = || println!("{}", greeting);
print_greeting();
println!("still have: {}", greeting); // OK
// Mutable borrow — closure modifies 'count'
let mut count = 0;
let mut increment = || {
count += 1;
println!("count: {}", count);
};
increment();
increment();
// println!("{}", count); // would error — mutably borrowed by closure
drop(increment); // closure dropped, borrow ends
println!("final count: {}", count); // OK now
// Move — closure takes ownership of 'data'
let data = vec![1, 2, 3];
let owns_data = move || println!("data: {:?}", data);
owns_data();
// println!("{:?}", data); // ERROR — data was moved into closure
}Hello still have: Hello count: 1 count: 2 final count: 2 data: [1, 2, 3]
The move Keyword
Adding move before the closure's pipes forces it to take ownership of every
captured variable, even those it only reads. This is essential when the closure
needs to outlive the current scope — most commonly when passing a closure to a
new thread.
use std::thread;
fn main() {
let message = String::from("Hello from thread!");
// Without 'move' this would not compile — the thread might outlive 'message'
let handle = thread::spawn(move || {
println!("{}", message);
});
handle.join().unwrap();
// 'message' was moved — cannot use it here
}Hello from thread!
move whenever a closure is sent to another thread or stored in a struct that outlives the current function. If you only need to share data across threads, wrap it in Arc before moving.The Three Fn Traits
Every closure in Rust automatically implements one or more of three traits that describe how it can be called. Understanding these traits is key to writing functions that accept closures as arguments.
Trait | Can be called | May consume captured values | May mutate captures |
|---|---|---|---|
FnOnce | Once only | Yes — may move out of captured values | Yes |
FnMut | Multiple times | No | Yes — mutates captured values |
Fn | Multiple times | No | No — only immutable access |
Every closure implements FnOnce. If it does not consume any captured value it also
implements FnMut. If it does not mutate any capture it also implements Fn.
This means Fn is the most restrictive and FnOnce the most permissive.
fn call_once(f: impl FnOnce() -> String) -> String {
f() // can only call f once
}
fn call_many_times(mut f: impl FnMut() -> i32) {
println!("{}", f());
println!("{}", f());
println!("{}", f());
}
fn call_without_side_effects(f: impl Fn(i32) -> i32) -> i32 {
f(1) + f(2) + f(3)
}
fn main() {
// FnOnce — consumes 'name' by moving it into the return value
let name = String::from("Alice");
let greeting = call_once(|| format!("Hello, {}!", name));
println!("{}", greeting);
// FnMut — mutates captured counter
let mut n = 0;
call_many_times(|| { n += 1; n });
println!("n is now: {}", n);
// Fn — only reads 'factor'
let factor = 3;
let total = call_without_side_effects(|x| x * factor);
println!("total: {}", total);
}Hello, Alice! 1 2 3 n is now: 3 total: 18
Closures as Function Arguments
Accepting a closure as a parameter is done with either impl Trait (for a concrete,
statically-dispatched closure) or a generic bound. Choose the weakest trait that
satisfies your needs — if you only call the closure once, use FnOnce; if you call
it many times without mutation, use Fn.
// impl Fn syntax — clean and idiomatic
fn apply_twice(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(f(x))
}
// Generic syntax — equivalent, useful when the bound is shared across parameters
fn apply_twice_generic<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(f(x))
}
fn main() {
let result = apply_twice(|x| x + 3, 10);
println!("10 + 3 + 3 = {}", result); // 16
let result2 = apply_twice_generic(|x| x * 2, 5);
println!("5 * 2 * 2 = {}", result2); // 20
}10 + 3 + 3 = 16 5 * 2 * 2 = 20
Closures as Return Values
Returning a closure from a function is slightly more involved because closures have anonymous, compiler-generated types. You have two options:
impl Fn(...)— concrete return type known at compile time (preferred)Box<dyn Fn(...)>— trait object, needed when the exact closure type is not known at compile time (e.g. choosing between two different closures at runtime)
// impl Fn — zero overhead, type known at compile time
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
// Box<dyn Fn> — needed when the return type varies at runtime
fn make_multiplier(double: bool) -> Box<dyn Fn(i32) -> i32> {
if double {
Box::new(|x| x * 2)
} else {
Box::new(|x| x * 3)
}
}
fn main() {
let add5 = make_adder(5);
let add10 = make_adder(10);
println!("add5(3) = {}", add5(3)); // 8
println!("add10(3) = {}", add10(3)); // 13
let f = make_multiplier(true);
println!("double 7 = {}", f(7)); // 14
let g = make_multiplier(false);
println!("triple 7 = {}", g(7)); // 21
}add5(3) = 8 add10(3) = 13 double 7 = 14 triple 7 = 21
impl Fn in return position requires that all branches return the same concrete closure type. If your function can return different closures depending on runtime conditions, use Box<dyn Fn>.Closures with Iterators
Closures shine when combined with Rust's iterator adapters. Methods like .map(),
.filter(), .for_each(), and .sort_by() all take closures and together form
a powerful, zero-overhead functional pipeline.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// filter + map + collect
let even_squares: Vec<i32> = numbers.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.collect();
println!("even squares: {:?}", even_squares);
// for_each
numbers.iter()
.filter(|&&x| x > 7)
.for_each(|x| print!("{} ", x));
println!();
// sort_by — sort strings by length
let mut words = vec!["banana", "apple", "fig", "cherry", "date"];
words.sort_by(|a, b| a.len().cmp(&b.len()));
println!("sorted by length: {:?}", words);
// fold — sum with a starting value
let sum: i32 = numbers.iter().fold(0, |acc, &x| acc + x);
println!("sum: {}", sum);
}even squares: [4, 16, 36, 64, 100] 8 9 10 sorted by length: ["fig", "date", "apple", "banana", "cherry"] sum: 55
Capturing and the Borrow Checker
Because closures borrow or move variables, they interact with the borrow checker. The most common pitfall is trying to use a captured variable after it has been moved, or holding a mutable closure and a shared reference simultaneously.
fn main() {
let mut items: Vec<i32> = vec![3, 1, 4, 1, 5];
// This works — sort_by takes &self, items is not moved
items.sort_by(|a, b| a.cmp(b));
println!("sorted: {:?}", items);
// Mutable closure borrows 'items' — cannot read 'items' during borrow
let mut push_item = || items.push(99);
push_item();
// println!("{:?}", items); // would fail — mutable borrow still active
drop(push_item); // end the borrow
println!("after push: {:?}", items);
}sorted: [1, 1, 3, 4, 5] after push: [1, 1, 3, 4, 5, 99]
Memoization with Closures
A practical use of closures is building a simple memoization wrapper — a structure that caches the result of an expensive computation and re-uses it on subsequent calls.
struct Memoize<T, F>
where
F: Fn(u32) -> T,
T: Clone,
{
func: F,
cache: Option<(u32, T)>,
}
impl<T, F> Memoize<T, F>
where
F: Fn(u32) -> T,
T: Clone,
{
fn new(func: F) -> Self {
Memoize { func, cache: None }
}
fn call(&mut self, arg: u32) -> T {
match &self.cache {
Some((cached_arg, cached_val)) if *cached_arg == arg => {
println!("(cache hit for {})", arg);
cached_val.clone()
}
_ => {
println!("(computing for {})", arg);
let result = (self.func)(arg);
self.cache = Some((arg, result.clone()));
result
}
}
}
}
fn expensive(n: u32) -> u32 {
// Simulate expensive work
n * n + 1
}
fn main() {
let mut memo = Memoize::new(expensive);
println!("result: {}", memo.call(5));
println!("result: {}", memo.call(5)); // cache hit
println!("result: {}", memo.call(7)); // new computation
}(computing for 5) result: 26 (cache hit for 5) result: 26 (computing for 7) result: 50
Returning Different Closures at Runtime
Sometimes you need to choose between closures dynamically. Because different closures
have different sizes (each closure is its own compiler-generated type), you must
heap-allocate them with Box to return them through a uniform interface.
fn make_operation(op: &str) -> Box<dyn Fn(f64, f64) -> f64> {
match op {
"add" => Box::new(|a, b| a + b),
"sub" => Box::new(|a, b| a - b),
"mul" => Box::new(|a, b| a * b),
"div" => Box::new(|a, b| if b != 0.0 { a / b } else { f64::NAN }),
_ => Box::new(|_, _| 0.0),
}
}
fn main() {
let ops = ["add", "sub", "mul", "div"];
for op in &ops {
let f = make_operation(op);
println!("{}: {}", op, f(10.0, 3.0));
}
}add: 13 sub: 7 mul: 30 div: 3.3333333333333335
Closures vs Functions: When to Use Which
Feature | Regular Function | Closure |
|---|---|---|
Syntax | fn name(params) -> R { } | |params| expr or |params| { } |
Can capture environment | No | Yes |
Type annotations | Required | Usually optional (inferred) |
Implements Fn traits | Yes (all three) | Yes (one or more) |
Can be stored in a variable | As function pointer fn() | Directly as a value |
Overhead | None | None for Fn/FnMut; heap alloc for Box<dyn Fn> |
Use when | Reusable, named logic | Short, inline, context-dependent logic |
Common Patterns at a Glance
fn main() {
// Short inline transformation
let numbers = vec![1, 2, 3];
let doubled: Vec<_> = numbers.iter().map(|x| x * 2).collect();
// Capture by reference for read-only work
let threshold = 5;
let big: Vec<_> = numbers.iter().filter(|&&x| x > threshold).collect();
// move for thread safety
let msg = String::from("hello");
std::thread::spawn(move || println!("{}", msg)).join().unwrap();
// Return a configured closure
let add_100 = |x: i32| x + 100;
println!("{}", add_100(42));
// FnOnce — consume and transform
let name = String::from("Rust");
let shout = || format!("{}!", name.to_uppercase());
println!("{}", shout());
println!("doubled: {:?}", doubled);
println!("big (>5): {:?}", big);
}hello 142 RUST! doubled: [2, 4, 6] big (>5): []