When data needs to move through a pipeline
You have a vector of transaction amounts. Some are negative returns. You need to filter out the negatives, double the remaining values for a bonus calculation, and then sum the total. In Python, you might write a list comprehension or a loop. In Rust, you chain methods: filter, map, sum. Each of those methods takes a closure. That's a higher-order function.
A higher-order function is a function that accepts another function as an argument or returns one. Think of a car wash. The car wash is the higher-order function. It has a fixed process: soap, rinse, dry. But the wax step? You can swap the wax. Sometimes you use a quick wax, sometimes a ceramic coating. The car wash stays the same; the wax logic changes. In Rust, map is the car wash. The closure you pass is the wax. You define the transformation, map handles the iteration.
Pass the logic, not the data.
The minimal example
Here is the standard pattern. You start with a collection, create an iterator, apply transformations, and collect the result.
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// iter() borrows the vector and returns an iterator.
// It yields references to the elements, not the values themselves.
let doubled: Vec<i32> = numbers
.iter()
// map takes a closure. It calls that closure for every item.
// The closure receives a reference (&i32) because iter() yields references.
.map(|x| x * 2)
// collect() consumes the iterator and builds a Vec.
// The type annotation tells the compiler what to build.
.collect();
println!("{:?}", doubled); // [2, 4, 6, 8, 10]
}
Rust developers reach for iterators over loops when transforming data. It signals intent. A loop says "do this repeatedly." An iterator says "transform this collection." The compiler also optimizes iterator chains aggressively, often generating code identical to a hand-written loop.
Lazy evaluation means zero work until you collect. Chain as much as you want; the cost is the same as a single loop.
What happens under the hood
When you call iter(), Rust creates an iterator struct that holds a reference to the vector and an index. Calling map does not run your closure. It returns a new iterator struct, Map, that holds the original iterator and your closure. Nothing has happened yet.
When you call collect, the chain activates. collect asks the Map iterator for the next item. Map asks the underlying iterator for the next item, calls your closure on that item, and returns the result. This repeats until the iterator is exhausted.
This lazy behavior is powerful. You can chain filter, map, enumerate, and take without creating intermediate allocations. The work happens item by item, exactly once.
The compiler enforces the contract. If the closure captures state, the trait bound tells you how many times you can call it.
Realistic transformation
Real code rarely doubles integers. You usually process structs, filter by conditions, and extract fields.
#[derive(Debug)]
struct User {
name: String,
age: u32,
}
fn main() {
let users = vec![
User { name: "Alice".to_string(), age: 17 },
User { name: "Bob".to_string(), age: 25 },
User { name: "Charlie".to_string(), age: 16 },
];
// Filter keeps items where the closure returns true.
// Map transforms the remaining items.
// We clone the name because we need to own the String in the result Vec.
let adult_names: Vec<String> = users
.iter()
.filter(|u| u.age >= 18)
.map(|u| u.name.clone())
.collect();
println!("{:?}", adult_names); // ["Bob"]
}
Convention aside: when you need a type hint for collect, use Vec<_> instead of Vec<String>. The underscore tells the compiler to infer the inner type from the closure's return type. It reduces noise while keeping the code correct.
Read the chain left to right. It describes the data flow, not the control flow.
The ah-ha: Option and Result
Higher-order functions aren't just for iterators. Option and Result have map and and_then. You can chain logic on nullable values just like collections.
fn main() {
let value = Some(5);
// map transforms the inner value if it exists.
// If value were None, the result would be None without branching.
let doubled = value.map(|n| n * 2);
println!("{:?}", doubled); // Some(10)
// and_then is like map, but the closure returns an Option.
// It flattens the result automatically.
let result = value.and_then(|n| {
if n > 3 {
Some(n * 10)
} else {
None
}
});
println!("{:?}", result); // Some(50)
}
This unifies your mental model. Treat Option like a collection with zero or one item. The same methods apply. You avoid nested if let blocks and write linear transformations.
Treat Option like a collection with zero or one item. The same mental model applies.
Pitfalls and compiler errors
Closures capture their environment. This is where Rust's borrow checker shines, and where beginners trip.
If your closure captures a mutable reference, you cannot call the closure twice if the borrow overlaps. The compiler rejects this with E0502 (cannot borrow as mutable because it is also borrowed as immutable).
fn main() {
let mut x = 5;
// This closure captures x by mutable reference.
let mut increment = || {
x += 1;
};
// Error: E0502.
// You borrowed x mutably for the closure, then tried to read x immutably.
println!("{}", x);
increment();
}
If your closure takes ownership of a variable, the value moves into the closure. Using the variable afterward triggers E0382 (use of moved value).
fn main() {
let s = String::from("hello");
// This closure takes ownership of s.
let consume = || {
println!("{}", s);
};
// Error: E0382.
// s moved into the closure. It no longer exists here.
println!("{}", s);
}
Higher-order functions have trait bounds. map requires a closure that implements Fn. This trait promises the closure can be called multiple times without consuming its captured environment. If your closure takes ownership, it implements FnOnce, and map rejects it. This is a safety feature. It prevents you from using a value after it's been moved.
The compiler protects you from capturing the wrong thing. Read the error; it tells you exactly what the closure grabbed.
Closures are structs, not functions
Closures are anonymous structs generated by the compiler. They implement traits: Fn, FnMut, or FnOnce. The trait depends on how the closure captures variables.
- If the closure captures by reference, it implements
Fn. - If it captures by mutable reference, it implements
FnMut. - If it captures by value, it implements
FnOnce.
map requires Fn. filter requires Fn. fold requires FnMut because the accumulator changes. for_each requires FnMut. These bounds ensure the iterator can call the closure the correct number of times.
You rarely need to write these traits manually. The compiler infers them. But understanding them explains why certain closures work in some methods and not others.
Match the tool to the shape of the data. The compiler optimizes iterator chains aggressively; trust the chain.
When to use what
Use map when you need to transform every element in a collection into a new value. Use filter when you need to keep only elements that satisfy a condition. Use fold when you need to reduce a collection to a single accumulated value, like summing or building a complex structure. Use a for loop when you have side effects, like printing or writing to a file, or when the logic is too complex for a chain. Use enumerate when you need the index alongside the value during transformation. Use Option::map when handling nullable values without branching. Use Result::and_then when chaining operations that can fail.
Pick the iterator method that matches your intent. The compiler optimizes the chain better than you can write a loop.