Lazy by Default: An Adapter Chain Does Nothing Until Something Pulls
An iterator adapter is a machine that wraps another machine: map, filter, enumerate, zip, take, chain all implement Iterator by defining a new next() that calls the inner one. Nothing executes until a consumer calls next() — which is why this prints nothing at all:
let prices = vec![10.0, 20.0, 30.0];
let plan = prices.iter().map(|p| p * 1.21).filter(|p| *p > 25.0); // no work yet
println!("plan ready"); // the only output
// the closures run only inside the loop below, one element at a time
for taxed in plan { println!("{taxed}"); }
Each next() on the chain performs exactly one map and one filter for one element and hands the result along. The pipeline is a description, and consuming it is an evaluation — the same relationship as a SQL query without SELECT, or a Vec of closures you haven't called.
for x in data.iter().map(f).filter(p) { ... } // pulls until the source is exhausted
Laziness has three practical payoffs:
- Short-circuiting is free.
find,any,all,take, andpositionstop pulling as soon as they have their answer, sodata.iter().filter(p).next()runspuntil the first match — not over the whole collection. - You can chain "infinite" sources.
std::iter::successors,repeat_with, and(0..)are unbounded iterators;take(n)bounds them.iter::repeat(0).take(3)is three elements, computed on demand. - Intermediate
collects disappear. Because the chain is one pass with no temporary vectors,data.iter().filter(p).map(f).collect::<Vec<_>>()allocates once — for the result — whereforloops with push-into-a-new-Vec per stage allocate per stage.
The types involved are opaque and zero-cost: each adapter is a distinct generic struct, monomorphized and inlined into one tight loop, with no dyn, no boxing, no allocation for the iterator itself. A well-formed chain typically compiles to the same machine code as the manual loop — often better, because the fused loop lets the optimizer keep the index in a register and skip redundant bounds checks.
The pull-based contract is also why mutating a collection while iterating it is a compile error rather than a subtle corruption: the iterator holds a borrow of the source for its lifetime (you met this with Vec in Vec and Dynamic Arrays). And when you store iterators across calls, prefer std::iter::FusedIterator-implementing sources (all std iterators are fused: once next() returns None, it keeps returning None) — relying on that guarantee in a state machine avoids restarting a half-consumed pipeline.
Two ordering notes interviewers probe: adapters apply outside-in at build time but run in source order — data.iter().map(a).filter(b).map(c) processes element 1 through a, b, c, then element 2 through a, b, c; and filter before map versus after changes how much work runs, even when the result is identical. Ordering the chain so the cheapest, most selective step runs first is a real optimization on large inputs.
You can watch the pull discipline directly by putting println! in a closure:
let doubled: Vec<i32> = (1..=3)
.map(|x| { println!("map {x}"); x * 2 })
.filter(|x| { println!("filter {x}"); *x > 2 })
.collect();
// map 1 / filter 2 / map 2 / filter 4 / map 3 / filter 6 — element by element,
// not "all maps, then all filters": each next() drives the whole chain for one item
That interleaving — one element through the entire pipeline before the next begins — is what makes streaming over an unbounded source possible at all: there is no intermediate Vec of mapped values waiting for the filter to run.
collect: The One Consumer That Decides the Destination Type
collect is unusual among consumers: it does almost nothing itself. Its signature returns Self::Item-agnostic generic output — fn collect<B: FromIterator<Self::Item>>(self) -> B — so the target collection type comes from context, usually a type annotation or the binding it feeds:
let v: Vec<usize> = (0..10).collect(); // range -> Vec
let s = (0..10).collect::<std::collections::HashSet<_>>(); // turbofish when no context exists
let joined: String = words.iter().map(|w| w.as_str()).collect::<Vec<_>>().join(", ");
That FromIterator impl is the extension point: any type that implements it can be a destination. The standard library provides it for Vec, String, HashSet, BTreeSet, VecDeque, LinkedList, BinaryHeap, and the map types (via tuples of (K, V) items), which covers most needs:
let set: std::collections::HashSet<char> = name.chars().collect(); // dedupe for free
let ranked: std::collections::BTreeMap<String, u32> =
scores.iter().map(|(k, v)| (k.to_uppercase(), *v)).collect(); // sorted by key
The two conversions that show up constantly:
// 1. Iterator<Item = Result<T, E>> -> Result<Vec<T>, E> (first error aborts the whole collect)
let nums: Vec<u16> = lines.iter().map(|l| l.trim().parse()).collect::<Result<Vec<u16>, _>>()?;
// 2. Iterator<Item = Option<T>> -> Option<Vec<T>> (first None aborts)
let ports: Option<Vec<u16>> = raw.iter().filter_map(|x| x.parse().ok()).collect();
Both exist because FromIterator is implemented for Result and Option over fallible item streams — the "all-or-nothing" semantics of ?, expressed as a collection. When you want the opposite (skip failures, keep successes), insert filter_map(...ok()) first and let the collect see plain values.
Writing the trait for your own type is a five-line exercise that makes collect feel less magical:
#[derive(Default)]
struct Sentences(Vec<String>);
impl FromIterator<String> for Sentences {
fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
let mut out = Vec::new();
for s in iter { if !s.trim().is_empty() { out.push(s); } }
Sentences(out)
}
}
let doc: Sentences = lines.map(|l| l.to_string()).collect();
The related Extend trait is the push side — vec.extend(iter), map.extend(pairs) — and is what makes +=-style accumulation generic over collections. Together they define a two-way relationship with iterators: IntoIterator turns your type into a stream, FromIterator builds your type from one, and collect is the bridge between them. A type that implements both participates fully in the ecosystem, which is why Vec, String, and every map type do.
The other half of the topic is the consuming operators, which drive the iterator to a single value and cannot be chained further:
| Consumer | Result | Notes |
|---|---|---|
sum::<T>(), product() |
number | needs Sum/Product; annotate the type |
count() |
usize |
runs the whole chain, counting pulls |
any(p), all(p), find(p) |
bool / Option |
short-circuit at the decisive element |
fold(init, f) |
anything | general accumulator; for loop in expression form |
for_each(f) |
() |
side-effecting loop without the for syntax |
min(), max(), last() |
Option<Item> |
last() consumes everything (unless double-ended) |
fold is the one worth internalizing: iter.fold(0, |acc, x| acc + x) is sum, iter.fold(String::new(), |mut s, x| { s.push_str(x); s }) builds text, and iter.fold(HashMap::new(), |mut m, x| { *m.entry(x).or_insert(0) += 1; m }) counts occurrences — all in one pass with the accumulator's type inferred from the initial value. When an interview asks you to "group these records by key in one expression," fold plus entry is the complete answer; collect then gives the finished map a home.
Pairing Data: enumerate, zip, and the Truncation Trap
Two adapters do the "attach an index" and "merge two streams" jobs, and each has one sharp edge worth knowing cold.
enumerate converts Item into (usize, Item), counting from zero:
for (i, line) in lines.iter().enumerate() {
println!("{i:3}: {line}");
}
// error reporting that matches the editor
if let Some((i, bad)) = records.iter().enumerate().find(|(_, r)| !r.valid()) {
return Err(AppError::BadRow { row: i + 1 }); // humans count from 1
}
The edge: the count always starts at 0, and your users count from 1 — off-by-one errors in file/row/column messages almost always come from forgetting + 1. (There is no enumerate_from; add the offset yourself, or skip with skip(n) first for an already-numbered source.)
zip pulls from two iterators in lockstep and stops at the shorter one:
let ids = vec!["a", "b", "c"];
let vals = vec![1, 2];
let pairs: Vec<_> = ids.iter().zip(vals.iter()).collect();
// [('a', 1), ('b', 2)] — 'c' is silently dropped
That silent truncation is the trap. If equal lengths are a requirement, assert it rather than trusting the zip:
assert_eq!(ids.len(), vals.len(), "ids and values must align");
let pairs: Vec<_> = ids.iter().zip(vals.iter()).collect();
When the length check cannot be made, zip on (a.into_iter(), b.into_iter()) paired with a follow-up .count() comparison is the honest version — the data tells you whether anything was left over.
Together they read as real production one-liners:
// interpolate two columns into a report line
for ((name, before), after) in names.iter().zip(&before).zip(&after) {
writeln!(out, "{name}: {before} -> {after}")?;
}
// running index + value in a fold
let total: usize = items.iter().enumerate()
.map(|(i, v)| i * v)
.sum();
The zip-of-zip shape deserves a pause: a.zip(b).zip(c) yields ((A, B), C), which destructures as ((a, b), c) — nesting left-associatively. Deeper nests get unreadable, and the fix is to zip tuples of references you already own or to name a small struct at that point.
zip also powers the "windows of pairs" pattern — iter.clone().skip(1).zip(iter) gives each element paired with its successor, the standard way to detect transitions or compute deltas without indexing:
let deltas: Vec<i64> = readings.windows(2).map(|w| w[1] - w[0]).collect();
(For a slice, windows(2) is clearer and stays in bounds by construction.)
A few companions round out the pairing toolkit: rev() walks a DoubleEndedIterator backwards (prices.iter().rev().take(3) is the three most recent, oldest-first collections excluded), step_by(n) samples every nth element, and chain(other) concatenates two sources when the two columns arrive as separate iterators rather than slices. Watch the interaction with enumerate in these forms: v.iter().rev().enumerate() numbers from 0 at the reversed end, so "the last item is index 0" — if you need its original position, enumerate before rev(). This ordering detail is a favorite follow-up question precisely because it produces wrong-but-plausible output rather than an error.
Interview framing: "enumerate gives (index, item) starting at 0 — watch the off-by-one in user-facing messages; zip merges two iterators and stops at the shorter, so equal-length assumptions must be asserted." Follow with the type it produces ((A, B)) and the FromIterator result when collected, and you have covered what they are actually testing.
One more pairing habit that shows up in real code: tuples from zip/enumerate destructure straight into closure parameters, which keeps a chain readable without temporary bindings —
let total: i64 = ids.iter().zip(&scores)
.map(|(id, score)| { report(id, score); i64::from(*score) })
.sum();
— and the same pattern feeds for ((k, a), b) in ... loops when three streams are zipped.
Quiz
1. What happens when you build `data.iter().map(expensive).filter(check)` and never iterate it?
2. `let v = items.iter().map(parse).collect();` fails with "type annotations needed". Why, and what is the fix?
3. Two iterators of lengths 5 and 3 are `zip`ped and collected. What do you get?
4. You must find the first element failing a predicate and stop scanning as early as possible. Which chain?
Flashcards
Question
Why does a chain like `iter().map(f).filter(p)` cost nothing until you iterate it?
Click to reveal answer
Answer
Adapters are lazy: each one is a struct implementing `Iterator` by wrapping the inner iterator's `next()`. Closures run per element only when a consumer pulls — `for`, `collect`, `find`, `sum`. An unconsumed chain performs no calls and allocates nothing.
Question
What does `collect` actually do, and how does Rust know which collection to build?
Click to reveal answer
Answer
It calls `FromIterator::from_iter` on the iterator; the concrete destination is inferred from the binding, the enclosing signature, or a turbofish like `collect::<HashSet<_>>()`. Implement `FromIterator` for a custom type and it becomes collectible too.
Question
Give the two `collect` conversions for fallible item streams and their semantics.
Click to reveal answer
Answer
`Iterator<Item = Result<T, E>>.collect::<Result<Vec<T>, E>>()` — collects all values or returns the *first* error; `Iterator<Item = Option<T>>.collect::<Option<Vec<T>>>()` — all values or the first `None`. Use `filter_map`/`ok()` before collecting when you'd rather skip failures.
Question
What are the consuming adapters, and which ones short-circuit?
Click to reveal answer
Answer
`sum`/`product`, `count`, `fold`, `for_each`, `min`/`max`, `last` — full passes; `any`, `all`, `find`, `position`, `nth`, `take` — short-circuit at the decisive element. Consumers drive `next()` to a single value, so the iterator cannot be reused afterwards.
Question
The `enumerate` and `zip` gotchas in one line each.
Click to reveal answer
Answer
`enumerate` yields `(usize, Item)` starting at 0 — add `+ 1` for human-facing row/line numbers. `zip` stops at the shorter iterator and drops the tail of the longer one silently — assert equal lengths when alignment matters.
Revision Notes
Key Takeaways
- 1. Adapters define a new `next()` and run nothing until a consumer pulls — which yields free short-circuiting, infinite-source support, and machine code comparable to a hand-written loop
- 2. `collect` is `FromIterator` in disguise: the target type comes from context, and any type implementing the trait can be a destination
- 3. Collecting `Result`/`Option` item streams gives all-or-nothing semantics; insert `filter_map`/`ok()` first to skip instead of abort
- 4. Consumers split into full-pass (`sum`, `count`, `fold`, `for_each`) and short-circuiting (`any`, `all`, `find`, `position`) — pick by whether early exit is correct
- 5. `enumerate` starts at 0 (add 1 for humans) and `zip` truncates to the shorter source (assert lengths when alignment is a requirement)
Interview Tips
- • "\"Explain laziness in iterators\" — adapters only wrap `next()`; closures run per pull; connect it to short-circuit `find`, unbounded sources with `take`, and why no intermediate Vecs appear"
- • "\"Why do I get 'type annotations needed' on collect?\" — `collect` is generic over `FromIterator`; show the binding annotation and the turbofish, and mention custom types can implement `FromIterator`"
- • "\"Write a word-frequency counter in one expression\" — `text.split_whitespace().fold(HashMap::new(), ...)` with `entry().or_insert(0)`; note it is a single pass with no intermediate allocation"
- • "\"What's wrong with this zip?\" — the shorter side truncates silently; say you assert lengths (or compare counts) before merging, and that `enumerate` needs `+1` for messages humans read"
Cheat Sheet
Iterator Adapters
Lazy pipeline (no work until pulled):
data.iter().map(f).filter(p).enumerate().zip(other) // all lazy, zero alloc, zero dyn
for x in pipeline { ... } // pulls one element at a time
iter.take(n) / skip(n) / chain(other) / peekable() // control the flow
Ordering matters for cost: put the cheapest, most selective step first. Never mutate the source while iterating (the iterator holds a borrow).
Choose the destination:
let v: Vec<_> = it.collect();
let s = it.collect::<HashSet<_>>(); // dedupe
let m: BTreeMap<_, _> = it.collect(); // sorted keys
let v: Vec<T> = it.collect::<Result<Vec<T>, _>>()?; // all-or-nothing with `?`
let v: Option<Vec<T>> = it.collect(); // aborts on first None
Consumers:
| Want | Use |
|---|---|
| number | sum::<T>(), count() |
| test / locate (short-circuit) | any, all, find, position |
| accumulator | fold(init, f) |
| side effects | for_each / for |
| group + count in one pass | fold(HashMap::new(), ...) + entry |
Pairing:
for (i, x) in it.enumerate() { ... } // 0-based: +1 for user-facing numbers
let pairs: Vec<_> = a.zip(b).collect(); // length = min(len_a, len_b) — assert if it matters
let deltas = w.windows(2).map(|p| p[1] - p[0]); // slice successor pairs