rust · level 7

Iterators & Closures

Laziness, Fn / FnMut / FnOnce, common adaptors.

150 XP

Iterators & Closures

Rust's iterators are the most powerful general-purpose abstraction in the language. They are lazy, zero-cost, and fused — a chain of map, filter, fold compiles to the same machine code as a hand-written loop, with the side benefit of being readable.

Closures provide the lambdas that iterators consume, and they come with three traits — FnOnce, FnMut, Fn — that classify them by how they capture and how often they can be called.

The Iterator trait

A single trait with a single required method:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ...dozens of provided methods
}

Implement next, and you get map, filter, take, skip, chain, zip, fold, sum, count, min, max, collect, etc. for free. Any type that pulls one item at a time can become an iterator.

The standard ways to get an iterator from a collection:

let v = vec![1, 2, 3];

v.iter()           // Iterator<Item = &i32>      — borrows
v.iter_mut()       // Iterator<Item = &mut i32>  — mutable borrow
v.into_iter()      // Iterator<Item = i32>       — consumes the vec

for x in v is sugar for v.into_iter(). for x in &v is sugar for v.iter(). for x in &mut v is v.iter_mut().

Adapters vs consumers

Two kinds of methods:

  • Adapters return another iterator. They do nothing on their own. map, filter, take, skip, chain, zip, enumerate, flatten, flat_map, filter_map, inspect, peekable...
  • Consumers drive the iterator forward and produce a value. collect, sum, product, count, fold, for_each, find, any, all, min, max, last, nth, reduce...

This compiles, runs nothing, and emits a warning:

(1..10).map(|x| x * 2).filter(|&x| x > 5);   // ⚠ unused, no consumer

This actually does the work:

let sum: i32 = (1..10).map(|x| x * 2).filter(|&x| x > 5).sum();

The chain is described declaratively; the consumer pulls values through it on demand.

collect — the universal consumer

collect materialises an iterator into any collection that implements FromIterator:

let v: Vec<i32> = (0..5).collect();
let s: String = ['h', 'i'].iter().collect();
let m: HashMap<&str, i32> = [("a", 1), ("b", 2)].iter().copied().collect();
let set: HashSet<i32> = vec![1, 1, 2, 3].into_iter().collect();

When the target type is ambiguous, use the turbofish syntax to spell it out:

let v = (0..5).collect::<Vec<i32>>();
let v: Vec<_> = (0..5).collect();           // or annotate the binding

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

let parsed: Result<Vec<i32>, _> = vec!["1", "2", "abc"].iter()
    .map(|s| s.parse::<i32>())
    .collect();
// parsed is Err(...) — bails on "abc"

fold — the swiss army knife

let sum = (1..=10).fold(0, |acc, x| acc + x);
let product = (1..=5).fold(1, |acc, x| acc * x);
let joined = ["a", "b", "c"].iter()
    .fold(String::new(), |mut s, x| { s.push_str(x); s });

fold(init, |acc, x| new_acc) carries an accumulator through every item. Most other consumers are special-case folds: sum is fold(zero, |a, x| a + x); count is fold(0, |a, _| a + 1).

reduce(|a, b| ...) is fold without an explicit init — it uses the first element as the seed, returning Option<T> (None if the iterator is empty).

The closure traits

Closures are anonymous functions that capture free variables from their environment:

let factor = 10;
let multiply = |x: i32| x * factor;       // captures `factor`
multiply(5);                              // 50

The compiler classifies each closure into one of three traits, based on what it captures and how:

Trait Captures by Can be called Example
Fn Shared reference (&T) Many times, even concurrently |x| x + factor (read-only)
FnMut Exclusive reference (&mut T) Many times, but one at a time |x| { count += 1; x }
FnOnce By value (T) Once — captures are consumed || { drop(s) }

Fn ⊂ FnMut ⊂ FnOnce. A closure that implements Fn also implements FnMut and FnOnce. The compiler always picks the most permissive (Fn if possible, falling back to FnMut, finally FnOnce).

You usually don't write these traits explicitly. They appear as bounds when you write higher-order functions:

fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
fn apply_once<F: FnOnce() -> String>(f: F) -> String { f() }
fn each<F: FnMut(i32)>(xs: &[i32], mut f: F) { for &x in xs { f(x) } }

move keyword — force capture by value

By default, closures capture in the most permissive way that works. move forces by-value capture:

let s = String::from("hello");
let owned = move || println!("{}", s);    // closure now owns s
// println!("{}", s);                     // ❌ s was moved into the closure

move is essential for closures that escape their defining scope — most commonly when spawning a thread:

let v = vec![1, 2, 3];
std::thread::spawn(move || {
    println!("{:?}", v);                  // v lives in the new thread
});

Without move, the new thread might outlive v, which would dangle. The compiler refuses; move is the fix.

Common adapters worth memorising

// Mapping
(1..=3).map(|x| x * 2);                        // 2, 4, 6
(1..=3).map(|x| x.to_string());                // String items

// Filtering
(1..=10).filter(|&x| x % 2 == 0);              // 2, 4, 6, 8, 10
xs.iter().filter_map(|s| s.parse::<i32>().ok()); // skip non-numeric

// Pairing / windowing
xs.iter().enumerate();                         // (0, &x0), (1, &x1), ...
xs.iter().zip(ys.iter());                      // (&x0, &y0), ...
(1..).take(5);                                 // first 5 of an infinite range

// Flattening
vec![vec![1, 2], vec![3, 4]].into_iter().flatten();   // 1, 2, 3, 4
xs.iter().flat_map(|s| s.chars());                    // chars across all strings

// Side-effects (rarely a good idea — use for_each at the consumer end)
xs.iter().inspect(|x| println!("saw {x}")).count();

Consumers worth memorising

let xs = vec![1, 2, 3, 4, 5];

xs.iter().sum::<i32>();                        // 15
xs.iter().product::<i32>();                    // 120
xs.iter().count();                             // 5
xs.iter().min();                               // Some(&1)
xs.iter().max();                               // Some(&5)

xs.iter().any(|&x| x > 3);                     // true (short-circuits)
xs.iter().all(|&x| x > 0);                     // true
xs.iter().find(|&&x| x > 2);                   // Some(&3)
xs.iter().position(|&x| x == 4);               // Some(3)

xs.iter().for_each(|x| println!("{x}"));       // side-effect consumer

Many of these short-circuit — any stops at the first true, find stops at the first match, take_while stops at the first false. Lazy iterators make this efficient by construction.

Closures as return types — impl Trait

fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

let add5 = make_adder(5);
add5(3);   // 8

impl Fn(...) -> ... says "some specific anonymous type that implements Fn". Each closure has a unique compiler-generated type — impl Trait lets you return them without naming.

For storing different closures in the same place (e.g. a Vec), use a trait object:

let actions: Vec<Box<dyn Fn(i32) -> i32>> = vec![
    Box::new(|x| x + 1),
    Box::new(|x| x * 2),
];

Box<dyn Fn> is dynamic dispatch through a vtable — slightly slower, more flexible.

Why this all matters

The for loop you'd write in any other language is in Rust competing with a chain of iterator methods that:

  • compose without intermediate allocations,
  • describe intent clearly without manual indexing,
  • benefit from the same compiler optimisations as the loop,
  • and integrate with rayon so .iter() becomes .par_iter() for free parallelism.

It takes a few weeks for the patterns to feel natural. Once they do, almost every imperative loop in your code rewrites into something shorter and harder to mis-bound.

Tools in the wild

4 tools
  • itertoolsfree tier

    Extra iterator adaptors: `chunks`, `tuples`, `unique`, `group_by`, `cartesian_product`.

    library
  • rayonfree tier

    Parallel iterators — swap `.iter()` for `.par_iter()` and the work is split across threads.

    library
  • clippyfree tier

    Suggests iterator chains over manual loops, flags `collect()` -> `for` patterns.

    cli
  • cargo-asmfree tier

    Inspect the generated assembly to confirm iterator chains compile to tight loops.

    cli