Laziness and Zero-Cost Abstractions

Understand how Rust iterators use lazy evaluation to achieve zero-cost abstractions, compiling down to efficient loops without runtime overhead.

Iterators in Rust don’t do any work until you ask for a value. That’s the core of lazy evaluation. Combine that property with the compiler’s ability to erase the abstraction entirely, and you get zero-cost abstractions—high-level, expressive code that runs as fast as a hand‑written loop.

This section builds on iterator adapters and closures from earlier in the chapter. Here you’ll learn exactly what laziness means, how the compiler turns iterator chains into tight machine code, and how to verify that the abstraction truly costs nothing.

What Lazy Evaluation Means for Iterators

An iterator in Rust is a state machine that produces values on demand. Creating an iterator—or chaining adapters like map and filter—does not start processing any data. Nothing runs until a consuming method (like for, sum, collect, or next) actually requests elements.

The code below makes that visible:

let numbers = vec![1, 2, 3];
let lazy_map = numbers.iter().map(|x| {
    println!("Processing {x}");
    x * 2
});
println!("Iterator created — nothing printed yet.");
// Consumption happens here.
for val in lazy_map {
    println!("Got {val}");
}

This prints:

Iterator created — nothing printed yet.
Processing 1
Got 2
Processing 2
Got 4
Processing 3
Got 6

The map closure runs only when the for loop pulls each item, and it runs exactly once per element. No temporary collection is allocated, and no work is wasted on elements that might never be requested. That’s lazy evaluation at work—the pipeline describes what to do, and the consumer decides when to do it.

Lazy does not mean slow:

In Rust, lazy is a scheduling choice, not a performance penalty. Because the compiler sees the entire pipeline at compile time, it can optimize the sequence of operations into a single efficient loop.

How Iterator Chains Defer All Work

Every iterator adapter (.map(...), .filter(...), .take(...), etc.) wraps the previous iterator in a new struct that implements the Iterator trait. Each struct’s next method does the minimum work to produce the next output value, calling the inner iterator only when necessary.

Consider a chain that filters even numbers and then squares them:

let result: Vec<i32> = (0..10)
    .filter(|&x| x % 2 == 0)
    .map(|x| x * x)
    .collect();

At the point .collect() is called, nothing has been computed yet. The filter adapter holds the original range iterator and a closure. Its next method loops internally, skipping values that don’t match, then returns the first matching one. The map adapter’s next calls the filtered iterator and applies the squaring closure. All of this unfolds on the fly, one element at a time, without building any intermediate Vec for the filtered numbers.

The compiler, through inlining, can fuse these separate next calls into a single loop body. The result is assembly that looks nearly identical to a manual loop with an if guard and a multiplication.

Zero-Cost Abstractions – The Compiler’s End of the Deal

A zero-cost abstraction is a language feature that adds no runtime overhead compared to writing the equivalent low‑level code by hand. Rust iterators are a prime example.

The compiler achieves this through three mechanisms:

  1. Monomorphization – Generic iterator adapters are compiled separately for each concrete type combination. There is no runtime dispatch; all function calls are known at compile time.
  2. Inlining – The next methods of adapters are small enough that the compiler inlines them into the consuming loop, removing call overhead and exposing the logic for further optimization.
  3. Loop fusion and dead‑code elimination – After inlining, LLVM sees a single loop containing all the operations. It can fuse conditionals, arithmetic, and memory accesses, eliminate redundant bounds checks, and apply the same optimizations it would for a hand‑written loop.

The result: an iterator chain like (0..n).filter(…).map(…).sum() becomes a tight loop with no extra allocations and no function‑call overhead.

What you use, you couldn’t hand‑code better:

Rust’s guiding principle echoes Bjarne Stroustrup’s definition of zero‑overhead: “What you don’t use, you don’t pay for. And further: what you do use, you couldn’t hand code any better.” In release builds, the compiler genuinely reaches that ideal for iterator pipelines.

A Concrete Look: What the Compiler Produces

Take a slightly more involved example—an inner product over a fixed‑size window, reminiscent of signal processing:

fn predict(buffer: &[i32], coeffs: &[i64; 12], qlp_shift: i16) -> i64 {
    coeffs.iter()
        .zip(&buffer[0..12])
        .map(|(&c, &s)| c * s as i64)
        .sum::<i64>() >> qlp_shift
}

This uses zip, map, and sum—three levels of abstraction. When compiled in release mode, the generated assembly shows no iterator structs, no function calls for closures, and no loop for the inner product: the compiler unrolls the 12‑element iteration completely and hoists the coefficients into registers. The result is a block of movslq, imul, and add instructions, followed by a shift—exactly the instructions you’d write by hand. All bounds checks are elided because the compiler proves the slice access is safe.

That’s zero‑cost: the high‑level iterator code vanishes and leaves only the essential arithmetic.

How Closures Fit Into the Picture

Many iterator adapters take closures—map’s transformation, filter’s predicate, for_each’s action. Those closures are zero‑cost too.

When you write |x| x * 2, the compiler generates an anonymous struct that holds any captured variables and implements one of the Fn* traits. Because the adapter receives the closure as a generic type parameter, the call is statically dispatched. The compiler knows the exact code that will run, so it inlines the closure body directly into the loop.

let factor = 3;
let doubled: Vec<i32> = numbers.iter()
    .map(|x| x * factor)
    .collect();

Here the closure captures factor. After monomorphization, there’s no function pointer and no indirect call—just a multiplication instruction inside the fused loop. The closure struct itself is optimized away entirely.

Dynamic dispatch can break zero‑cost:

If you store a closure in a Box<dyn Fn(i32) -> i32>, you lose static dispatch. The compiler can no longer inline the call, and you pay for a vtable lookup on every invocation. Use impl Fn or generic bounds in iterator chains to keep the abstraction zero‑cost.

Common Pitfalls That Destroy Zero‑Cost

The iterator system is zero‑cost by design, but certain patterns can introduce hidden work.

Collecting Too Early

Calling .collect() creates a new collection. If you collect an intermediate result just to iterate over it again, you’ve added unnecessary heap allocation and a second traversal:

// Unnecessary allocation
let filtered: Vec<_> = numbers.iter().filter(|&x| x % 2 == 0).collect();
let squared: Vec<_> = filtered.iter().map(|x| x * x).collect();

The pipeline approach avoids this:

let squared: Vec<_> = numbers.iter()
    .filter(|&x| x % 2 == 0)
    .map(|x| x * x)
    .collect();

Only one Vec is allocated, and the loop runs once. The iterator’s size_hint even helps collect pre‑allocate the right capacity.

Cloning When You Could Borrow

Iterators over references yield references. Cloning inside a map when you only need a borrowed view adds allocations:

// Avoidable clone
let uppercased: Vec<String> = names.iter()
    .map(|s| s.clone().to_uppercase())
    .collect();

If String::to_uppercase can’t work on a reference, that’s one thing. But whenever a transformation can operate on a borrowed value, skip the clone:

// Better – no clone, the string is borrowed and returned.
let uppercased: Vec<String> = names.iter()
    .map(|s| s.to_uppercase())
    .collect();

Iterator chains that look functional but allocate internally:

Each .collect() or .clone() call in a chain introduces runtime work that the compiler cannot always eliminate. When performance matters, inspect your pipeline for avoidable allocations.

Verifying That the Abstraction Is Truly Zero‑Cost

You don’t have to take the compiler’s promise on faith. Two practical tools let you see the optimized output yourself.

1

Install cargo-asm

cargo-asm prints the assembly for a specific function. Install it once:

cargo install cargo-asm
2

Write a small test function

Place the iterator code in a standalone function so cargo-asm can locate it easily:

pub fn sum_squares() -> i32 {
    (0..1000)
        .filter(|&x| x % 2 == 0)
        .map(|x| x * x)
        .sum()
}
3

Inspect the release assembly

Run cargo asm with the binary and function name:

cargo asm --bin your_crate_name sum_squares

Look for a single loop with no calls to iterator helper functions. On x86‑64 you’ll typically see cmp, jne, arithmetic instructions, and stack accesses—but no indirect jumps or allocations.

You can also benchmark the iterator against a manual for loop with criterion. In the Rust Book’s own benchmarks (searching The Adventures of Sherlock Holmes), the iterator‑based search function performed within the same margin of error as the hand‑written loop. That parity is consistent across real‑world workloads.

If you see the same loop shape, you have zero‑cost:

In release builds, an iterator chain that looks identical in assembly to a manual loop is the definitive proof. No hidden allocations, no virtual calls—just the raw computation.

Beyond the Mechanics – How to Think About Iterator Performance

A beginner‑friendly mental model: an iterator pipeline is a description of work, not a piece of machinery that runs in the background. The consumer (a for loop, sum, collect) reads that description and does the work directly. The adapters are compile‑time instructions, not runtime objects that live on the heap.

When you write .filter(…).map(…), you’re telling the compiler, “I want a loop that does this.” The compiler rearranges those instructions into the most efficient loop it can. The closures are inlined to the point where they disappear. This isn’t magic—it’s just the same optimization pipeline that C and C++ compilers have been using for decades, applied to Rust’s type‑safe, high‑level constructs.

Misunderstanding this leads to the misconception that “iterators are slower than loops.” In unoptimized debug builds they are slower because the compiler keeps abstractions intact for debuggability. In release mode, the same code compiles to the same assembly. The difference vanishes.

Summary

Laziness and zero‑cost abstractions are two sides of the same coin. Laziness ensures that iterator adapters do nothing until consumed, avoiding wasted work and unnecessary allocations. Zero‑cost abstractions ensure that when the work does happen, it happens with the same efficiency as the most carefully optimized manual loop.

Together, they let you write expressive, compositional code that feels high‑level but runs as if it were written in C. The closures you pass to map, filter, and friends are inlined and statically dispatched, contributing no runtime overhead. The iterator chain itself is fused into a single loop by the compiler, with bounds checks removed and arithmetic simplified.

This is not a theoretical claim. You can verify it with cargo-asm, Godbolt, or benchmarks—and you’ll find that Rust delivers on its promise.