Rust Iterators: Zero-Cost Functional Pipelines vs Handwritten Loops
- Last updated
- Prerequisites:
- Rust basics (collections, traits)
- Understanding of compiler optimization and inlining
- rust
- iterators
- zero-cost
- optimization
- functional
- inlining
Read at your depth
The practical view
Rust iterators let you write functional pipelines: iter().filter(...).map(...).take(10).collect(). These are lazy: nothing runs until collected/consumed. The claim 'zero-cost' means the compiled output should be as fast as a handwritten loop — because the iterator adapters are generic and inline into each other. For example: nums.iter().filter(|n| n % 2 == 0).map(|n| n * 2).sum::<i32>() should optimize to a single loop with no iterator object overhead. Use iterators for clarity; they're designed to compile down to the same machine code as manual loops.
The same idea in other frameworks
N/A — Rust language feature.N/A — Rust feature.N/A — Rust feature.N/A — Rust feature.Legacy vs modern
Imperative loop with intermediate allocations vs iterator pipeline
An imperative version often allocates intermediate collections and repeats passes; the iterator pipeline fuses into one pass with no intermediate storage.
let mut evens = Vec::new(); // ❌ OLD: intermediate allocationnums.iter().filter(|n| n % 2 == 0).map(|n| n * 2).sum() // ✅ NEWInterview gotchas
Context
This tests the optimization model — interviewers want the monomorphization+inlining mechanism AND the honest cases where the promise can break.
The mechanical answer
Iterators are zero-cost when the chain is monomorphized and inlined: each adapter is a generic struct whose next() calls the inner next(); with concrete types, the compiler inlines the whole chain into a single loop, eliminating the iterator object and enabling loop optimizations (bounds-check elimination via size_hint, vectorization). The promise can break when: the chain is used across a function boundary without inlining (e.g. a boxed dyn Iterator, which forces dynamic dispatch and prevents fusion); closures that capture large state inhibit inlining; or when the iterator produces values that cannot be fused (e.g. collect() into a collection necessarily allocates). Also, if you call an adapter that needs the full collection (like .rev() on a double-ended iterator that requires RandomAccess), the fusion may differ. In general, for linear single-pass pipelines, iterators match hand loops.
Trap
The naive answer is 'iterators are always zero-cost by definition'. Zero-cost depends on inlining and concrete types; Box<dyn Iterator> and cross-crate non-inlined paths pay real costs. Another trap: assuming every collect() is free — collecting into a Vec allocates, exactly like building the Vec by hand. Candidates who explain the inlining dependency show real understanding.
Context
This tests the laziness model — interviewers want the next()-driven evaluation and the 'nothing runs until consumed' semantics that prevent surprises.
The mechanical answer
Iterator adapters are lazy: chaining .filter().map() only BUILDS the adapter structs — no filtering or mapping happens until something calls next() (or a consuming method like collect(), sum(), for loop). Each call to next() pulls one item through the whole chain: the innermost source produces the next element, each adapter transforms/filters it in turn. If you never consume the iterator, the closures never run and no work is done — this is why you can build a pipeline and conditionally consume it. Consuming methods (collect, sum, count, for_each) drive the chain to completion. This laziness is what allows infinite iterators (0.., repeat) as long as the consumer stops (take). Side effects in closures run per item when consumed, so 'side-effecting map' only executes during consumption.
Trap
The naive answer is 'the pipeline runs when you write it'. It runs only when consumed. Another trap: putting side effects in a filter/map closure and expecting them to run eagerly — they run once per consumed item, and if you consume twice (two collect calls on the same iterator source) they run twice. Candidates who describe the next()-driven pull model show real understanding.