Updated Jun 22, 2026

Closures, Iterators & Zero-Cost Abstractions - Expressive and Fast

Back in Phase 9 you got a taste of .iter().filter().map().collect() - the chain that reads like a sentence. It looked clean, maybe a little magical. This phase is where the magic turns into a mental model. We're going to crack open why those chains work, what the funny |x| ... syntax inside them actually is, and the claim that makes the whole thing remarkable: that this high-level, readable code runs exactly as fast as the low-level loop you'd dread writing by hand.

That claim has a name - zero-cost abstraction - and it's close to the heart of Rust's pitch. Most languages make you choose: write expressive code that's slow, or fast code that's ugly. Rust says you shouldn't have to. By the end of this phase you'll understand the three pieces that make it true: closures, the function traits, and the iterator protocol.

Closures - functions that remember where they came from

What it actually is. A closure is an anonymous function you can write inline - but with a superpower a plain function doesn't have: it can capture variables from the surrounding scope and carry them along. The syntax is a pair of pipes for the parameters, then the body: |x| x + 1.

📝 Closure - an anonymous function value that can capture (remember) variables from the environment where it was defined. "Closure" because it closes over the surrounding variables, keeping them alive inside the function.

fn main() {
    let add_one = |x: i32| x + 1;       // a closure, stored in a variable
    println!("{}", add_one(5));

    let offset = 100;                   // a variable in the surrounding scope
    let add_offset = |x: i32| x + offset; // the closure captures `offset`
    println!("{}", add_offset(5));
}
$ cargo run
6
105

What just happened: add_one is a function with no name, written where it's used. add_offset is the interesting one - it mentions offset, a variable from outside the closure, and Rust quietly captures it so the value travels with the closure. A plain fn can't do that; it has no access to local variables around it. That capturing is the entire reason closures exist.

By default a closure borrows what it captures. But sometimes you need it to own its captures instead - for example, to hand the closure to another thread (you saw this in Phase 14) or return it from a function. The move keyword forces capture by value:

fn main() {
    let name = String::from("Ada");

    // `move` takes ownership of `name` into the closure
    let greet = move || println!("Hello, {}", name);

    greet();
    // println!("{}", name);  // ERROR: `name` was moved into the closure
}
$ cargo run
Hello, Ada

What just happened: Without move, the closure would borrow name. With move, it took ownership - name now lives inside greet, and trying to use it afterward would be a compile error (it's been moved, exactly like the moves you learned in Phase 6). Use move when the closure needs to outlive the scope it was born in.

Fn, FnMut, FnOnce - how a closure treats its captures

Here's the question Rust has to answer for every closure: when it uses a captured variable, does it just read it, mutate it, or consume it entirely? The answer determines how many times you can call the closure and where you're allowed to use it. Rust encodes that answer as three traits, and a closure automatically implements whichever ones fit.

📝 FnOnce - a closure that consumes its captures (e.g. moves a value out). It can be called once, because after that the captured values are gone. Every closure is at least FnOnce.

📝 FnMut - a closure that mutably borrows its captures, so it can change them. Callable many times, but it needs mutable access while it runs.

📝 Fn - a closure that only immutably borrows its captures (reads them). Callable many times, freely, even from multiple places at once.

These nest from most-restrictive to most-permissive: every Fn is also an FnMut, and every FnMut is also an FnOnce. You almost never write these by hand - the compiler looks at what your closure body does and picks the most permissive trait that fits. Your job is mostly to know what they mean when they show up in function signatures.

And they show up constantly, because a function that accepts a closure is generic over one of these traits. Saying impl Fn(i32) -> i32 means "give me anything callable like this, that only reads its captures":

// Accepts any closure that takes an i32 and returns an i32, reading its captures.
fn apply_twice(f: impl Fn(i32) -> i32, start: i32) -> i32 {
    f(f(start))   // called twice - so it must be Fn, not FnOnce
}

fn main() {
    let bump = 10;
    let result = apply_twice(|x| x + bump, 5);  // closure captures `bump` by read
    println!("{}", result);
}
$ cargo run
25

What just happened: apply_twice calls f twice, so it demands impl Fn - a closure it can call repeatedly. We passed |x| x + bump, which only reads bump, so the compiler happily certifies it as Fn and the call typechecks. If apply_twice had asked for impl FnOnce instead, calling f a second time would fail to compile, because FnOnce only promises one call. This is how the borrow rules from Phase 6 extend cleanly to functions-as-values: the trait is the contract for how the closure touches its captures.

💡 Key point. When you read impl Fn(...) in a signature, translate it as "a callable that reads its captures, usable many times." FnMut is "callable many times, but mutates." FnOnce is "callable exactly once." You rarely choose which one your closure is - the compiler does - but reading them tells you instantly how a function intends to use the closure you hand it.

The Iterator trait - one method to rule them all

Now the centerpiece. Every loop, every .map(), every for you've written in Rust runs on a single, almost comically small trait.

📝 Iterator - a trait with one required method, fn next(&mut self) -> Option<Self::Item>. Call next() and it hands you Some(value) for the next item, or None when there's nothing left. Everything else - map, filter, sum, the works - is a default method built on top of next.

That tiny contract has a profound consequence: iterators are lazy. Calling next is the only thing that produces a value. Until something calls it, an iterator is an inert recipe - it describes work but does none of it.

You'll usually get iterators from collections (.iter(), .into_iter()), but implementing the trait yourself is the best way to see there's no magic. Here's a counter that yields the numbers below a limit:

struct Counter {
    count: u32,
    max: u32,
}

impl Iterator for Counter {
    type Item = u32;                    // what each item is

    fn next(&mut self) -> Option<u32> {
        if self.count < self.max {
            self.count += 1;
            Some(self.count)            // hand out the next value
        } else {
            None                        // signal "we're done"
        }
    }
}

fn main() {
    let counter = Counter { count: 0, max: 3 };
    for n in counter {                  // the `for` loop calls next() for us
        println!("{}", n);
    }
}
$ cargo run
1
2
3

What just happened: We implemented exactly one method, next. Each call bumps count, returns Some(count), and when we hit max it returns None. The for loop is doing nothing fancier than calling next() over and over and stopping the instant it sees None - the same protocol the Python guide describes for StopIteration, only here the "we're done" signal is the None variant of Option. Define next, and your type drops straight into every for loop and every adapter in the standard library.

Iterator adapters - building pipelines, lazily

Because everything is built on next, the standard library can offer a huge toolbox of methods that take an iterator and return a new iterator. These are adapters, and they're lazy - they wrap your iterator in another layer of "recipe" without running anything. The methods that actually drive the iterator and produce a final value are consumers, and they're eager.

A useful way to hold the two apart:

  • Adapters (lazy, return an iterator): map (transform each item), filter (keep some items), take (stop after N), zip (pair two iterators together), enumerate (attach an index to each item).
  • Consumers (eager, return a value): collect (gather into a collection), sum (add them up), fold (reduce with an accumulator), for_each (run a side effect per item).

The pattern is always: chain adapters to describe the transformation, then end with one consumer to make it happen.

fn main() {
    let names = ["alice", "bob", "carol", "dave"];

    let result: Vec<String> = names
        .iter()
        .enumerate()                          // (0, "alice"), (1, "bob"), ...
        .filter(|(i, _)| i % 2 == 0)          // keep even indices
        .map(|(i, name)| format!("{}: {}", i, name))
        .take(5)                              // at most 5 (we have fewer)
        .collect();                           // <-- the consumer fires it all

    for line in &result {
        println!("{}", line);
    }

    let total: u32 = (1..=5).sum();           // a one-shot consumer
    println!("sum 1..=5 = {}", total);
}
$ cargo run
0: alice
2: carol
sum 1..=5 = 15

What just happened: Read the chain top to bottom as a pipeline: number each name with enumerate, keep the even-indexed ones with filter, format each survivor with map, cap the count with take - and then collect pulls every value through and builds the Vec. Crucially, none of enumerate/filter/map/take did any work when they were written; they only described layers. The collect() at the end is what called next enough times to run the whole thing. The (1..=5).sum() line shows a range is also an iterator, consumed in one shot.

⚠️ Adapters do nothing until a consumer runs. This is the single most common iterator mistake. Write names.iter().map(|n| println!("{}", n)); with no consumer, and nothing prints - you built a recipe and threw it away. The compiler even warns you: warning: unused 'Map' that must be used / iterators are lazy and do nothing unless consumed. If your iterator chain seems to do nothing, you forgot the consumer at the end.

Zero-cost abstractions - readable and fast, not one or the other

So we have closures and a deeply layered iterator system. In most languages, layering like that costs you: each adapter would be an object with virtual method calls, heap allocations, the works - and the pretty pipeline would run measurably slower than a blunt for loop. Here's the part that makes Rust special: it doesn't.

📝 Zero-cost abstraction - a high-level construct that compiles down to the same machine code you'd have written by hand at the low level, with no runtime penalty for the abstraction. You don't pay for the niceness.

Three things from earlier phases combine to make this real. Monomorphization (Phase 11's generics): because apply_twice and every adapter are generic, the compiler stamps out a concrete, specialized version for your exact closure and type - no dynamic dispatch. Inlining: those small closures and next calls get inlined directly into the loop, so the layers of "recipe" collapse into flat code. And the ownership model (Phase 6) often lets the compiler prove indices are in bounds, eliding the array bounds-checks a naive loop might keep.

The upshot: this pipeline...

fn main() {
    let nums: Vec<u64> = (1..=1_000).collect();

    // High-level: reads like a sentence.
    let sum_of_even_squares: u64 = nums
        .iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * n)
        .sum();

    println!("{}", sum_of_even_squares);
}

...compiles to essentially the same instructions as this hand-rolled version:

fn main() {
    let nums: Vec<u64> = (1..=1_000).collect();

    // Low-level: the loop you'd write to avoid "overhead."
    let mut sum_of_even_squares: u64 = 0;
    for &n in &nums {
        if n % 2 == 0 {
            sum_of_even_squares += n * n;
        }
    }

    println!("{}", sum_of_even_squares);
}
$ cargo run
166666500

What just happened: Both versions produce 166666500, and after the optimizer runs, both produce nearly identical machine code - the iterator version has no extra allocations, no closure objects on the heap, no virtual calls. The filter and map closures were inlined into one tight loop. So the choice between them is purely about which one you'd rather read and maintain - the performance is the same.

💡 You don't trade readability for speed. That's the whole pitch of this phase, and arguably of Rust. In many languages the iterator chain is the "elegant but slower" option you avoid in hot loops. In Rust it's the idiomatic default precisely because it costs nothing - you get the sentence-like clarity and the bare-metal performance. Reach for the chain first; drop to a manual loop only when profiling gives you a concrete reason, which is rare.

Recap

  1. A closure is an anonymous function written inline (|x| x + 1) that can capture variables from its surrounding scope. By default it borrows them; move makes it take ownership - needed for threads or returning the closure.
  2. Fn / FnMut / FnOnce describe how a closure treats its captures: Fn reads (callable many times), FnMut mutates (callable many times), FnOnce consumes (callable once). The compiler picks the right one; a function accepting a closure is generic over these traits, e.g. impl Fn(i32) -> i32.
  3. The Iterator trait requires one method, next() -> Option<Item>. Everything else is built on it, and that means iterators are lazy - no value exists until something calls next.
  4. Adapters (map, filter, take, zip, enumerate) are lazy and return iterators; consumers (collect, sum, fold, for_each) are eager and produce a value. ⚠️ Adapters do nothing until a consumer runs - forget the consumer and you get the "unused iterator" warning and no output.
  5. Zero-cost abstraction: thanks to monomorphization, inlining, and the borrow checker, an iterator chain compiles to the same machine code as a hand-written loop. You get readable and fast - you don't trade one for the other.

Quick check

Test yourself on the three ideas that make this phase tick - capturing, laziness, and zero cost:

[
  {
    "q": "What distinguishes a closure like `|x| x + offset` from a plain `fn`?",
    "choices": [
      "It can capture variables from the surrounding scope (like `offset`), carrying them along",
      "It always runs faster than a named function",
      "It can only ever be called once",
      "It cannot take any parameters"
    ],
    "answer": 0,
    "explain": "A closure closes over its environment - it can capture and remember variables from where it was defined, which a plain `fn` cannot. By default it borrows them; `move` makes it take ownership."
  },
  {
    "q": "You write `names.iter().map(|n| println!(\"{}\", n));` and nothing prints. Why?",
    "choices": [
      "`map` is a lazy adapter - without a consumer like `collect`, `for_each`, or a `for` loop, nothing pulls the values through",
      "`println!` doesn't work inside a closure",
      "`.iter()` returns an empty iterator for arrays",
      "The closure needs the `move` keyword to run"
    ],
    "answer": 0,
    "explain": "Iterator adapters are lazy: they build a recipe but do no work until a consumer calls `next`. With no consumer, the chain is dropped unused - the compiler even warns 'iterators are lazy and do nothing unless consumed.'"
  },
  {
    "q": "Why does an idiomatic `.iter().filter(...).map(...).sum()` chain run as fast as a hand-written `for` loop in Rust?",
    "choices": [
      "Monomorphization and inlining collapse the adapters and closures into the same machine code as the manual loop - a zero-cost abstraction",
      "The chain secretly skips most of the elements to save time",
      "Rust runs iterator chains on a separate optimized thread",
      "It doesn't - the chain is always noticeably slower, so you should avoid it"
    ],
    "answer": 0,
    "explain": "The generic adapters get specialized (monomorphized) for your exact types and the small closures get inlined, so the layers collapse into one tight loop with no heap allocations or virtual calls. Readable and fast - that's the zero-cost promise."
  }
]

← Phase 14: Fearless Concurrency · Guide overview · Phase 16: Macros & Metaprogramming →

Check your understanding 3 questions

1. What distinguishes a closure like `|x| x + offset` from a plain `fn`?

2. You write `names.iter().map(|n| println!("{}", n));` and nothing prints. Why?

3. Why does an idiomatic `.iter().filter(...).map(...).sum()` chain run as fast as a hand-written `for` loop in Rust?