7. Closures and Higher-Order Functions π’
What you'll learn:
- The three closure traits (
Fn,FnMut,FnOnce) and how capture works- Passing closures as parameters and returning them from functions
- Combinator chains and iterator adapters for functional-style programming
- Designing your own higher-order APIs with the right trait bounds
Fn, FnMut, FnOnce β The Closure Traits
Every closure in Rust implements one or more of three traits, based on how it captures variables:
// FnOnce β consumes captured values (can only be called once)
let name = String::from("Alice");
let greet = move || {
println!("Hello, {name}!"); // Takes ownership of `name`
drop(name); // name is consumed
};
greet(); // β
First call
// greet(); // β Can't call again β `name` was consumed
// FnMut β mutably borrows captured values (can be called many times)
let mut count = 0;
let mut increment = || {
count += 1; // Mutably borrows `count`
};
increment(); // count == 1
increment(); // count == 2
// Fn β immutably borrows captured values (can be called many times, concurrently)
let prefix = "Result";
let display = |x: i32| {
println!("{prefix}: {x}"); // Immutably borrows `prefix`
};
display(1);
display(2);
The hierarchy: Fn : FnMut : FnOnce β each is a subtrait of the next:
FnOnce β everything can be called at least once
β
FnMut β can be called repeatedly (may mutate state)
β
Fn β can be called repeatedly and concurrently (no mutation)
If a closure implements Fn, it also implements FnMut and FnOnce.
Closures as Parameters and Return Values
// --- Parameters ---
// Static dispatch (monomorphized β fastest)
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(f(x))
}
// Also written with impl Trait:
fn apply_twice_v2(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(f(x))
}
// Dynamic dispatch (trait object β flexible, slight overhead)
fn apply_dyn(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
// --- Return Values ---
// Can't return closures by value without boxing (they have anonymous types):
fn make_adder(n: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |x| x + n)
}
// With impl Trait (simpler, monomorphized, but can't be dynamic):
fn make_adder_v2(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn main() {
let double = |x: i32| x * 2;
println!("{}", apply_twice(double, 3)); // 12
let add5 = make_adder(5);
println!("{}", add5(10)); // 15
}
Combinator Chains and Iterator Adapters
Higher-order functions shine with iterators β this is idiomatic Rust:
// C-style loop (imperative):
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let mut result = Vec::new();
for x in &data {
if x % 2 == 0 {
result.push(x * x);
}
}
// Idiomatic Rust (functional combinator chain):
let result: Vec<i32> = data.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.collect();
// Same performance β iterators are lazy and optimized by LLVM
assert_eq!(result, vec![4, 16, 36, 64, 100]);
Common combinators cheat sheet:
| Combinator | What It Does | Example |
|---|---|---|
.map(f) | Transform each element | `.map( |
.filter(p) | Keep elements where predicate is true | `.filter( |
.filter_map(f) | Map + filter in one step (returns Option) | `.filter_map( |
.flat_map(f) | Map then flatten nested iterators | `.flat_map( |
.fold(init, f) | Reduce to single value (like Aggregate in C#) | `.fold(0, |
.any(p) / .all(p) | Short-circuit boolean check | `.any( |
.enumerate() | Add index | `.enumerate().map( |
.zip(other) | Pair with another iterator | .zip(labels.iter()) |
.take(n) / .skip(n) | First/skip N elements | .take(10) |
.chain(other) | Concatenate two iterators | .chain(extra.iter()) |
.peekable() | Look ahead without consuming | .peek() |
.collect() | Gather into a collection | .collect::<Vec<_>>() |
Implementing Your Own Higher-Order APIs
Design APIs that accept closures for customization:
/// Retry an operation with a configurable strategy
fn retry<T, E, F, S>(
mut operation: F,
mut should_retry: S,
max_attempts: usize,
) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
S: FnMut(&E, usize) -> bool, // (error, attempt) β try again?
{
for attempt in 1..=max_attempts {
match operation() {
Ok(val) => return Ok(val),
Err(e) if attempt < max_attempts && should_retry(&e, attempt) => {
continue;
}
Err(e) => return Err(e),
}
}
unreachable!()
}
// Usage β caller controls retry logic:
# fn connect_to_database() -> Result<(), String> { Ok(()) }
# fn http_get(_url: &str) -> Result<String, String> { Ok(String::new()) }
# trait TransientError { fn is_transient(&self) -> bool; }
# impl TransientError for String { fn is_transient(&self) -> bool { true } }
# let url = "http://example.com";
let result = retry(
|| connect_to_database(),
|err, attempt| {
eprintln!("Attempt {attempt} failed: {err}");
true // Always retry
},
3,
);
// Usage β retry only specific errors:
let result = retry(
|| http_get(url),
|err, _| err.is_transient(), // Only retry transient errors
5,
);
The with Pattern β Bracketed Resource Access
Sometimes you need to guarantee that a resource is in a specific state for the
duration of an operation, and restored afterward β regardless of how the caller's
code exits (early return, ?, panic). Instead of exposing the resource directly
and hoping callers remember to set up and tear down, lend it through a closure:
set up β call closure with resource β tear down
The caller never touches setup or teardown. They can't forget, can't get it wrong, and can't hold the resource beyond the closure's scope.
Example: GPIO Pin Direction
A GPIO controller manages pins that support bidirectional I/O. Some callers need
the pin configured as input, others as output. Rather than exposing raw pin access
and trusting callers to set direction correctly, the controller provides
with_pin_input and with_pin_output:
/// GPIO pin direction β not public, callers never set this directly.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Direction { In, Out }
/// A GPIO pin handle lent to the closure. Cannot be stored or cloned β
/// it exists only for the duration of the callback.
pub struct GpioPin<'a> {
pin_number: u8,
_controller: &'a GpioController,
}
impl GpioPin<'_> {
pub fn read(&self) -> bool {
// Read pin level from hardware register
println!(" reading pin {}", self.pin_number);
true // stub
}
pub fn write(&self, high: bool) {
// Drive pin level via hardware register
println!(" writing pin {} = {high}", self.pin_number);
}
}
pub struct GpioController {
current_direction: std::cell::Cell<Option<Direction>>,
}
impl GpioController {
pub fn new() -> Self {
GpioController {
current_direction: std::cell::Cell::new(None),
}
}
/// Configure pin as input, run the closure, restore state.
/// The caller receives a `GpioPin` that lives only for the callback.
pub fn with_pin_input<R>(
&self,
pin: u8,
mut f: impl FnMut(&GpioPin<'_>) -> R,
) -> R {
let prev = self.current_direction.get();
self.set_direction(pin, Direction::In);
let handle = GpioPin { pin_number: pin, _controller: self };
let result = f(&handle);
// Restore previous direction (or leave as-is β policy choice)
if let Some(dir) = prev {
self.set_direction(pin, dir);
}
result
}
/// Configure pin as output, run the closure, restore state.
pub fn with_pin_output<R>(
&self,
pin: u8,
mut f: impl FnMut(&GpioPin<'_>) -> R,
) -> R {
let prev = self.current_direction.get();
self.set_direction(pin, Direction::Out);
let handle = GpioPin { pin_number: pin, _controller: self };
let result = f(&handle);
if let Some(dir) = prev {
self.set_direction(pin, dir);
}
result
}
fn set_direction(&self, pin: u8, dir: Direction) {
println!(" [hw] pin {pin} β {dir:?}");
self.current_direction.set(Some(dir));
}
}
fn main() {
let gpio = GpioController::new();
// Caller 1: needs input β doesn't know or care how direction is managed
let level = gpio.with_pin_input(4, |pin| {
pin.read()
});
println!("Pin 4 level: {level}");
// Caller 2: needs output β same API shape, different guarantee
gpio.with_pin_output(4, |pin| {
pin.write(true);
// do more work...
pin.write(false);
});
// Can't use the pin handle outside the closure:
// let escaped_pin = gpio.with_pin_input(4, |pin| pin);
// β ERROR: borrowed value does not live long enough
}
What the with pattern guarantees:
- Direction is always set before the caller's code runs
- Direction is always restored after, even if the closure returns early
- The
GpioPinhandle cannot escape the closure β the borrow checker enforces this via the lifetime tied to the controller reference - Callers never import
Direction, never callset_directionβ the API is impossible to misuse
Where This Pattern Appears
The with pattern shows up throughout Rust's standard library and ecosystem:
| API | Setup | Callback | Teardown |
|---|---|---|---|
std::thread::scope | Create scope | |s| { s.spawn(...) } | Join all threads |
Mutex::lock | Acquire lock | Use MutexGuard (RAII, not closure, but same idea) | Release on drop |
tempfile::tempdir | Create temp directory | Use path | Delete on drop |
std::io::BufWriter::new | Buffer writes | Write operations | Flush on drop |
GPIO with_pin_* (above) | Set direction | Use pin handle | Restore direction |
The closure-based variant is strongest when:
- Setup and teardown are paired and forgetting either is a bug
- The resource shouldn't outlive the operation β the borrow checker enforces this naturally
- Multiple configurations exist (
with_pin_inputvswith_pin_output) β eachwith_*method encapsulates a different setup without exposing the configuration to the caller
withvs RAII (Drop): Both guarantee cleanup. Use RAII /Dropwhen the caller needs to hold the resource across multiple statements and function calls. Usewithwhen the operation is bracketed β one setup, one block of work, one teardown β and you don't want the caller to be able to break the bracket.
FnMut vs Fn in API design: Use
FnMutas the default bound β it's the most flexible (callers can passFnorFnMutclosures). Only requireFnif you need to call the closure concurrently (e.g., from multiple threads). Only requireFnOnceif you call it exactly once.
Key Takeaways β Closures
Fnborrows,FnMutborrows mutably,FnOnceconsumes β accept the weakest bound your API needsimpl Fnin parameters,Box<dyn Fn>for storage,impl Fnin return (orBox<dyn Fn>if dynamic)- Combinator chains (
map,filter,and_then) compose cleanly and inline to tight loops- The
withpattern (bracketed access via closure) guarantees setup/teardown and prevents resource escape β use it when the caller shouldn't manage configuration lifecycle
See also: Ch 2 β Traits In Depth for how
Fn/FnMut/FnOncerelate to trait objects. Ch 8 β Functional vs. Imperative for when to choose combinators over loops. Ch 15 β API Design for ergonomic parameter patterns.
graph TD
FnOnce["FnOnce<br>(can call once)"]
FnMut["FnMut<br>(can call many times,<br>may mutate captures)"]
Fn["Fn<br>(can call many times,<br>immutable captures)"]
Fn -->|"implements"| FnMut
FnMut -->|"implements"| FnOnce
style Fn fill:#d4efdf,stroke:#27ae60,color:#000
style FnMut fill:#fef9e7,stroke:#f1c40f,color:#000
style FnOnce fill:#fadbd8,stroke:#e74c3c,color:#000
Every
Fnis alsoFnMut, and everyFnMutis alsoFnOnce. AcceptFnMutby default β itβs the most flexible bound for callers.
Exercise: Higher-Order Combinator Pipeline β β (~25 min)
Create a Pipeline struct that chains transformations. It should support .pipe(f) to add a transformation and .execute(input) to run the full chain.
struct Pipeline<T> {
transforms: Vec<Box<dyn Fn(T) -> T>>,
}
impl<T: 'static> Pipeline<T> {
fn new() -> Self {
Pipeline { transforms: Vec::new() }
}
fn pipe(mut self, f: impl Fn(T) -> T + 'static) -> Self {
self.transforms.push(Box::new(f));
self
}
fn execute(self, input: T) -> T {
self.transforms.into_iter().fold(input, |val, f| f(val))
}
}
fn main() {
let result = Pipeline::new()
.pipe(|s: String| s.trim().to_string())
.pipe(|s| s.to_uppercase())
.pipe(|s| format!(">>> {s} <<<"))
.execute(" hello world ".to_string());
println!("{result}"); // >>> HELLO WORLD <<<
let result = Pipeline::new()
.pipe(|x: i32| x * 2)
.pipe(|x| x + 10)
.pipe(|x| x * x)
.execute(5);
println!("{result}"); // (5*2 + 10)^2 = 400
}