The Book·Chapter 13·15 min

Iterators and closures

Closures, capture modes, iterator adapters and terminals, and why .map().collect() is usually right.

Iterators and closures are how idiomatic Rust expresses transformations. A for-loop that accumulates into a Vec is almost always a .map().collect() in disguise. A nested loop with a counter is almost always .enumerate(). Once you read the iterator vocabulary, every loop in the standard library looks like clutter.

For an agent orchestrator, the pattern to watch for is the five-line for-loop that should have been one chained expression, and its dangerous cousin: .unwrap() inside a .map(). The unwrap is invisible in code review until production panics on a row that the test data did not cover.

Closures

A closure is an anonymous function that can capture variables from the enclosing scope.

let threshold = 10;
let above = |x: i32| x > threshold;
 
let nums = vec![1, 5, 12, 8, 20];
let big: Vec<i32> = nums.into_iter().filter(|&n| above(n)).collect();

Closure syntax |args| expression. Multi-line bodies use braces. Type annotations are usually optional; the compiler infers them from how the closure is used.

Capture modes: Fn, FnMut, FnOnce

The compiler infers how each captured variable is used and assigns the closure to one of three traits:

TraitWhat it capturesHow many times callable
FnBy shared referenceMany times
FnMutBy mutable referenceMany times
FnOnceBy valueOnce
// Fn: reads `name`.
let name = String::from("ev");
let greet = || println!("hello, {name}");
greet();
greet();
 
// FnMut: mutates `count`.
let mut count = 0;
let mut tick = || { count += 1; };
tick();
tick();
 
// FnOnce: consumes `owned`.
let owned = String::from("ev");
let consume = move || drop(owned);
consume();
// consume(); // ERROR: owned was moved.

move forces the closure to capture by value even when by-reference would compile. You need move when the closure outlives the captured variable's scope (thread::spawn, tokio::spawn, anything stored in a struct).

The Iterator trait

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
 
    // 70+ default methods built on top of `next`.
}

Every adapter is built on next(). The trait is lazy: chaining .map(), .filter(), .take() does not run any work. A terminal method (collect, sum, count, for_each, ...) drives the chain by calling next until it returns None.

let v = vec![1, 2, 3, 4, 5];
 
let sum: i32 = v.iter()
    .map(|x| x * 2)
    .filter(|x| x > &4)
    .sum();
 
assert_eq!(sum, 24); // 6 + 8 + 10

The chain compiles to a tight loop. No intermediate vectors, no heap allocation, no virtual calls. The optimizer fuses everything.

The adapters you'll actually use

AdapterShape
map(f)Transform each item.
filter(p)Drop items where p returns false.
take(n)Stop after n items.
skip(n)Skip the first n items.
enumerate()Yield (index, item) pairs.
zip(other)Pair items with another iterator. Stops at the shorter one.
chain(other)Concatenate two iterators.
flat_map(f)Map each item to an iterator, flatten the result.
peekable()Adds .peek() to look at the next item without advancing.
fuse()Once None is returned, always return None.
step_by(n)Yield every nth item.
windows(n) (slice)Sliding overlapping windows.
chunks(n) (slice)Non-overlapping chunks.

The terminals you'll actually use

TerminalShape
collect()Gather into a Vec, String, HashMap, etc.
sum() / product()Numeric reduction.
count()How many items.
fold(init, f)Reduce with an initial value.
reduce(f)Reduce without an initial value. Returns Option.
find(p)First item where p is true. Returns Option.
any(p)Does any item match? Short-circuits.
all(p)Do all items match? Short-circuits.
for_each(f)Side-effect for each item.
max() / min()The largest / smallest. Returns Option.

Short-circuiting on errors with collect

A useful trick: collect::<Result<Vec<T>, E>>() short-circuits on the first Err.

let inputs = vec!["1", "2", "oops", "4"];
 
let parsed: Result<Vec<i32>, _> = inputs.iter()
    .map(|s| s.parse::<i32>())
    .collect();
 
match parsed {
    Ok(v) => println!("all good: {:?}", v),
    Err(e) => eprintln!("first failure: {e}"),
}

The compiler sees you're collecting Iterator<Item = Result<i32, _>> into Result<Vec<i32>, _> and generates the short-circuit logic. No need for a hand-written loop with manual ?.

Returning impl Iterator

When a function produces an iterator, return impl Iterator<Item = ...> so the chain can compose at the call site without an intermediate Vec.

pub fn evens(n: u32) -> impl Iterator<Item = u32> {
    (0..n).filter(|x| x % 2 == 0)
}
 
let total: u32 = evens(100).sum();

The caller gets all the laziness, none of the type-name pain.

Bad vs good

△ Bad
fn line_lengths(lines: &[String]) -> Vec<usize> {
  let mut out = Vec::new();
  for line in lines {
      out.push(line.len());
  }
  out
}

fn first_int(values: &[&str]) -> i32 {
  let mut total = 0;
  for v in values {
      let n = v.parse::<i32>().unwrap();
      total = n;
      break;
  }
  total
}
◇ Good
fn line_lengths(lines: &[String]) -> Vec<usize> {
  lines.iter().map(|line| line.len()).collect()
}

fn first_int(values: &[&str]) -> Result<i32, std::num::ParseIntError> {
  values
      .iter()
      .next()
      .map(|v| v.parse::<i32>())
      .transpose()
      .map(|opt| opt.unwrap_or(0))
}

The bad version's .unwrap() inside the loop is a hidden panic site. The good version returns a Result and lets the caller decide. Either approach is fine: just do not bury the panic.

Performance

Iterator chains usually match hand-written loops in machine code. The LLVM optimizer fuses adapters, eliminates intermediate allocations, and vectorizes. The exceptions are real but narrow: very deep chains (10+ adapters) can confuse the optimizer, and .collect() followed by another iteration that could have been chained is wasteful.

Profile before rewriting an iterator as a loop. The "iterators are slow" intuition is usually wrong.