← all lessons
Types & Traits · #9 of 13

Closures and Iterators — Pipelines That Process Data

Anonymous functions and lazy, composable data pipelines

Almost every useful program does the same three things to a list: keep some of it, change each piece, and add up the result. Rust has one mechanism for all three, and it compiles down to a loop so tight there is nothing left to make faster.

This lesson is about writing that pipeline — and about the little anonymous functions that ride inside it.

Most data-shaping work in real programs — filter a list, transform each element, sum the result — runs through a single mechanism in Rust: the iterator. Chains of iterators read like a recipe, top to bottom, but they have a surprising property: each step is lazy. Nothing actually happens until something at the end asks for the answer. The compiler then fuses the whole chain into machine code that walks the input exactly once.

The other half of this lesson is the closure — a small function with no name that remembers values from the place it was born. Closures and iterators almost always travel together: the closure says what to do to each item, the iterator decides when.

A few words you’ll need

Closures: anonymous functions that remember

The smallest closure is just a pair of bars and a body. Here’s one that captures a value from the surrounding scope — tax_rate is never passed in as an argument, yet the closure can see it. Hit Run:

A closure that captures tax_rate editable · real rustc
Open in Playground ↗ ready

|price: f64| ... is a closure taking one parameter and returning a value. No fn keyword, no explicit return type — both are inferred. The interesting word is tax_rate: it isn’t a parameter, but the closure still reads it because the compiler quietly bundles the captured variable into the closure value. That bundle — function plus the environment it closed over — is the whole reason the thing is called a “closure.”

How does the capture happen — by copy, by reference, by move? Rust decides automatically based on what the body does:

Those three modes line up with three traits — Fn, FnMut, and FnOnce — that you’ll meet in function signatures. You almost never write them by hand; you read them and let inference do the work. Fn can be called many times, FnMut can be called many times but mutates as it goes, and FnOnce can be called exactly once because calling it uses something up.

That last mode — capture-by-value — is also what move forces. Prefixing a closure with move says “take ownership of every variable you capture,” which is essential when the closure outlives the scope it was made in (for example, when it’s sent to another thread, the topic of lesson 11).

The FnOnce trap: a closure you can only call once

Here’s a closure that moves a String out of itself and returns it. Returning the captured value consumes it, so the closure is FnOnce — calling it a second time is impossible. Hit Run and read the error:

Call a FnOnce closure twice — and watch it fail editable · real rustc
Open in Playground ↗ ready

The compiler stops the build with error[E0382] and a precise diagnosis: “closure cannot be invoked more than once because it moves the variable banner out of its environment” and “this value implements FnOnce, which causes it to be moved when called.” The very first call consumed the closure; the second call has nothing left to run. This is the same ownership rule from lessons 4–5, now applied to the data a closure carries. Fix it by capturing a clone inside, or by returning a reference instead of the owned String.

Iterators: laziness is the feature

An iterator is anything that implements one method:

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

next returns Some(item) for each value, then None forever after. That one method is the entire contract.

Every type that yields a sequence — Vec<T>, arrays, the characters of a string, ranges like 0..10, the lines of a file, packets off a socket — implements Iterator. So do all the adapters (map, filter, and friends), which wrap an upstream iterator and reshape what its next returns.

The crucial property: adapters don’t do anything on their own. They build up a description of a pipeline. The pipeline runs only when a terminal method (collect, sum, a for loop, …) finally walks it and pulls values through.

Portrait of Barbara Liskov, MIT computer scientist
Barbara Liskov · b. 1939 Invented iterators as a language abstraction in CLU (1970s, MIT), pioneered data abstraction, and won the 2008 Turing Award. Rust's Iterator trait is a direct descendant of her work.

The canonical pipeline

Now the payoff. This chain keeps the prices over 10, knocks 2 off each, and totals the result — and separately collects the kept prices into a fresh Vec. Read it top-down like a recipe, then hit Run:

filter → map → sum, and a collect editable · real rustc
Open in Playground ↗ ready

It prints kept = [12, 30, 25, 18] and total after discount = 77. Walking the chain:

  1. prices.iter() produces an iterator over references into the array (&i32 values).
  2. .filter(...) takes a closure returning bool; it keeps only items where the closure returned true.
  3. .map(...) takes a closure and transforms each surviving item into a new value.
  4. .sum() and .collect() are the terminals. sum adds everything up; collect gathers results into a collection (the Vec<i32> annotation tells it which collection to build).

The double-ampersand |&&p| is destructuring: iter() yields &i32, and filter hands the closure a reference to that, so we peel off two &s to reach a plain i32 named p. It’s a small wart of iterating over references; you get used to it. (copied() in the second line turns the &i32s back into owned i32s so collect builds a Vec<i32> rather than a Vec<&i32>.)

The other trap: a pipeline nobody consumes

Because adapters are lazy, a chain with no terminal does nothing at all — not even the side effects inside it. This is the single most common beginner surprise. The map below has a println! in it, yet because nothing consumes the iterator, those lines never print. Hit Run and read both the output and the compiler warning:

A map with no consumer — silence (and a warning) editable · real rustc
Open in Playground ↗ ready

The program compiles and runs, but the only line it prints is the last one. No processing 1/2/3 ever appears, because the map was never pulled through by a terminal. Rust even warns you: “iterators are lazy and do nothing unless consumed.” The fix is to add a terminal — .for_each(...), .collect::<Vec<_>>(), .sum::<i32>(), or a for loop. Laziness is a feature (it’s what makes long chains cheap), but it bites the first time you forget the consumer.

Lazy is also fast

Because each adapter is lazy, a long pipeline doesn’t build throwaway collections between steps:

let result: u64 = (0u64..1_000_000)
    .filter(|n| n % 2 == 0)
    .map(|n| n * n)
    .sum();

That walks the range once, filtering and squaring on the fly. There is no temporary Vec of even numbers and no second Vec of squares — just the running sum. In a release build (cargo build --release), the compiler typically optimises the whole chain down to the same machine code as a hand-written for loop: no heap allocation, no per-item function-call overhead. This is what zero-cost abstraction means in practice — high-level, readable code that costs nothing at runtime versus the loop you’d have written by hand.

Three ways to start a pipeline from a collection

When you turn a Vec<T> into an iterator, you choose what happens to the collection:

MethodYieldsEffect on the collection
iter()&T (shared references)Borrows; collection still usable afterward
iter_mut()&mut T (mutable references)Mutably borrows; can modify each item
into_iter()T (owned values)Consumes; collection is moved

This is just the ownership story from lessons 4–5 in a new costume. for x in &v desugars to a call that uses iter(); for x in v (no &) uses into_iter() and consumes the vec. Pick into_iter only when you’re done with the source — try to use v afterward and you’ll get the move error you now know by heart.

Why the chain compiles to nothing extra: iterators are structs, not callbacks

In a garbage-collected language, map and filter usually allocate a list or call a function through a pointer for every element. Rust does neither. Each adapter is a plain struct that holds the upstream iterator plus your closure, and its next method calls the upstream next inline. So xs.iter().filter(f).map(g) is, at the type level, a Map<Filter<Iter<...>>> — a nested struct with no heap, no dyn, no indirection.

When the optimiser sees .sum() drive that nested next, it inlines every layer and collapses the whole thing into one loop. The closures f and g get inlined too, because their concrete types are known at compile time (this is monomorphisation, the same mechanism behind generics in lesson 8). The result is genuinely identical to the hand-written loop — sometimes faster, because the iterator form can prove the bounds checks away. The abstraction is “zero-cost” not by promise but by construction: there is nothing for it to cost.

Key takeaways

A closure says what; an iterator says when; the compiler decides how — and the how it picks is the fastest one available, every time.

That is the quiet luxury of Rust: you write the readable version, and you get the hand-tuned version for free. Next, in lesson 10, we face the things that can go wrong — and how Rust makes you handle them without ever throwing an exception.