Home

Rust iterators really do cost nothing

Coming from Swift, where map and filter allocate intermediate arrays unless you remember to say .lazy, I did not believe Rust’s claim that iterator chains compile to the same code as a hand-written loop.

So I checked.

The experiment

pub fn sum_of_even_squares(values: &[i32]) -> i32 {
    values.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| x * x)
        .sum()
}

pub fn sum_of_even_squares_loop(values: &[i32]) -> i32 {
    let mut total = 0;
    for &x in values {
        if x % 2 == 0 {
            total += x * x;
        }
    }
    total
}

Compiled with --release and inspected with cargo asm, these produce the same machine code — both auto-vectorised into SIMD instructions processing several integers per cycle.

Not “similar performance”. The same instructions.

Why it works

Three things combine, and none of them is magic.

Iterators are structs, not objects. values.iter().filter(…).map(…) builds a value of type Map<Filter<Iter<i32>, closure>, closure>. There is no allocation, no boxing, and no dynamic dispatch — the closure types are baked into the type.

Everything is inlined. Each adapter’s next() is a small function marked for inlining. After inlining, the chain of next() calls collapses into one loop body.

LLVM optimises what is left. Once it is a single loop over a slice with a branch and a multiply, the ordinary optimiser handles it — including vectorisation.

The key structural point is that iteration is pull-based. Nothing happens until sum() asks for a value, and each request travels down the chain and back. There is no intermediate collection at any point, because no adapter ever holds more than one element.

Tip

This is the same design as Swift’s lazy — but in Rust it is the default and the only option. map on an iterator has no eager version, which is why the abstraction never quietly costs you an allocation.

Where it stops being free

Three cases, all of them visible in the code.

collect() allocates. Obviously — you asked for a collection. What is less obvious is that chaining collect() in the middle of a pipeline defeats the whole mechanism:

// two allocations, two passes
let result: Vec<_> = values.iter().map(|x| x * 2).collect();
let result: Vec<_> = result.iter().filter(|x| **x > 10).collect();

// no allocation until the end
let result: Vec<_> = values.iter().map(|x| x * 2).filter(|x| *x > 10).collect();

Box<dyn Iterator> breaks inlining. Type-erasing an iterator means the compiler cannot see through next() any more, so nothing collapses and every element costs a virtual call.

Some adapters must buffer. sorted is not an iterator adapter for a reason — sorting needs all the elements. rev() on a non-double-ended iterator, peekable, and chunks all hold state, though usually a constant amount.

collect() is cleverer than it looks

collect is generic over the target type, which lets it do things that look like separate functions in other languages:

let vec: Vec<i32> = iter.collect();
let set: HashSet<i32> = iter.collect();
let map: HashMap<String, i32> = pairs.collect();
let string: String = chars.collect();

// the one worth knowing
let results: Result<Vec<i32>, ParseError> = strings.iter().map(|s| s.parse()).collect();

That last one is genuinely useful and not obvious: collecting an iterator of Result into a Result<Vec<_>, _> short-circuits on the first error and returns it. The same works for Option. It replaces a loop with an early return, and it is one line.

What I changed in my own code

Mostly I stopped worrying. Specifically:

I stopped writing manual loops for performance. The chain is clearer and produces the same instructions.

I stopped intermediate collect()s. These were the only real cost in my code, and there were several — usually written because a chain got long and I broke it up for readability. A let binding of the iterator itself does the same thing without allocating.

I use iter(), iter_mut() and into_iter() deliberately. Borrow, mutably borrow, consume. Most of my early borrow-checker fights were reaching for the wrong one of these three.

The honest caveat

“Zero cost” means zero runtime cost relative to the hand-written equivalent. It is not free in two other senses.

Compile time. Every adapter is a new type and monomorphisation generates code for each one. Long iterator chains are measurably slower to compile than loops.

Debuggability. Stepping through an inlined ten-adapter chain in a release build is unpleasant, and a panic inside a closure gives you a backtrace full of iterator internals.

Neither of those has been enough to make me write a loop instead. But “zero cost” is a claim about the generated code, not about the whole experience, and I would rather say that than repeat the slogan.