Skip to content
advanced Phase 8 · Collections and Iterators

Custom Iterators and Laziness

Implement the Iterator trait and understand why chains cost nothing until used.

45m
0 problems
Topic Progress 0%

Implementing Iterator: One Method Unlocks Every Adapter in the Std Library

You have a type that generates values — a counter, a retry schedule, a sensor sampling loop — and you want map, filter, sum, for to work on it without writing them yourself. The whole protocol is one associated type and one method:

struct Countdown {
    remaining: u8,
}

impl Iterator for Countdown {
    type Item = u8;                        // what each step yields

    fn next(&mut self) -> Option<u8> {     // None == end of sequence
        if self.remaining == 0 {
            None
        } else {
            self.remaining -= 1;
            Some(self.remaining + 1)
        }
    }
}

let liftoff: Vec<u8> = Countdown { remaining: 3 }.collect();   // [3, 2, 1]
let total: u8 = Countdown { remaining: 10 }.sum();             // 55

That is the entire contract: next(&mut self) returns Some(item) while values remain and None when finished. Every adapter you already know — map, filter, enumerate, zip, take, chain, collect — is implemented against that contract only, so your three-line type inherits the entire Iterator toolkit with zero extra code. This is the payoff of trait-based design in one screenshot: the std library authors wrote the adapters once, against next, and never again per container.

Two optional overrides improve the machinery without changing behavior:

impl Iterator for Countdown {
    // ...
    fn size_hint(&self) -> (usize, Option<usize>) {
        (self.remaining as usize, Some(self.remaining as usize))
    }
}

size_hint lets collect pre-allocate exactly — Vec::with_capacity for a known count, no wasted buffer, no growth copies. Supply it when you can compute the remaining count cheaply and it is exact or a correct lower bound (over-promising is undefined behavior territory for adapters that trust it, so never guess upward). Beyond that, std::iter::FusedIterator (marker: once None, always None) and ExactSizeIterator (marker: len() is exact) let consumers take faster paths; both are claims about your implementation, and the compiler cannot check them — implement them only when true.

Common shapes for next, so you recognize them immediately:

Pattern next body
countdown / bounded decrement until 0, else None
over a slice yield self.items.get(self.i)? then increment
over nested state while let Some(v) = self.inner.next() { ... } — inner ? and continue
until an error occurs store Result state, yield once, then None

An iterator delegating to another (next pulling from self.inner) is how composite iterators are written, and the ? inside next works because Option is the return type — the same operator from the error-handling topics, wearing its other hat.

The interview question this topic exists for: "implement an iterator over the Fibonacci sequence." The correct answer is a struct holding the two state values, an Iterator impl whose next computes and yields the next number, and a note that fib().take(10).collect() then works — plus, if you mention size_hint returning (0, None), you have shown you know the protocol beyond the required method:

struct Fib { a: u64, b: u64 }

impl Iterator for Fib {
    type Item = u64;
    fn next(&mut self) -> Option<u64> {
        let out = self.a;
        (self.a < u64::MAX / 2).then(|| { let c = self.a + self.b; self.a = self.b; self.b = c; });
        Some(out)
    }
}

let even_fib_sum: u64 = Fib { a: 0, b: 1 }
    .take_while(|x| *x < 4_000_000)
    .filter(|x| x % 2 == 0)
    .sum();

Note what did not appear: no Vec, no index bookkeeping, no while loop — the state lives in the struct, and every std adapter composes on top of it.

Laziness, Monomorphization, and the Cost of an Intermediate collect

Because adapters are lazy structs and generic types are monomorphized, a chain compiles into a single loop with the closures inlined — no vtable, no allocation, no function-pointer indirection:

let hits: Vec<_> = requests.iter()
    .filter(|r| r.status == 200)
    .map(|r| r.latency_ms)
    .collect();

Under cargo build --release, this typically matches a hand-written for loop instruction for instruction; benchmarks in the wild show iterator chains faster than naive loops more often than slower, because the fused single-pass structure exposes bounds-check elimination and keeps the index in a register. Laziness also means the chain allocates exactly once — for the result Vec — whereas staged loops that build a temporary Vec per stage allocate and copy per stage.

The mistake laziness invites is materializing in the middle:

// Two full passes over N elements, plus an N-element temporary allocation
let a: Vec<_> = data.iter().filter(p).collect();
let b: Vec<_> = a.iter().map(f).collect();

// One pass, zero temporaries
let b: Vec<_> = data.iter().filter(p).map(f).collect();

Each .collect() closes the current pipeline, allocates, and materializes — turning O(n) work into O(n) with an extra constant per intermediate, and turning two logical stages into two traversals (cache-unfriendly for large inputs). Only collect when a value must exist: a final result, something handed across a function boundary, or a Vec you need to mutate while iterating. When you need an intermediate result for debugging, inspect(|x| println!("{x:?}")) is an adapter — it stays lazy and costs nothing in release builds if you remove it... which is the point: keep the chain intact and delete the probe later.

Returning lazy iterators from functions is where the design gets interesting, because you cannot name the compiler-generated type:

// Zero cost, opaque type — the caller sees `impl Iterator<Item = &Request>`
fn bad(requests: &[Request]) -> impl Iterator<Item = &Request> {
    requests.iter().filter(|r| r.status >= 500)
}

// Costly but type-erased — needed when the chain differs per branch
fn either(reqs: &[Request], strict: bool) -> Box<dyn Iterator<Item = &Request> + '_> {
    if strict {
        Box::new(reqs.iter().filter(|r| r.status >= 400))
    } else {
        Box::new(reqs.iter().filter(|r| r.status >= 500))
    }
}

impl Iterator (named in Rust 2024 as -> impl Iterator) keeps everything static and inlinable but can only represent one concrete type per function — different branches with different adapters need Box<dyn Iterator> (one allocation per box, dynamic dispatch) or an enum implementing Iterator manually. Choose impl by default, Box<dyn> only when the pipeline truly varies at runtime.

One rule about ending: consumers like fold and for stop when they see None, and std iterators are fused — but a hand-rolled Iterator whose next can return Some after None (state bugs, restarting an inner iterator) breaks adapters like fuse-based combinators and MultiPeek. Make "after None, forever None" part of your next implementation, and derive it explicitly with impl FusedIterator for Countdown {} when you have proven it.

There is one more laziness trick worth knowing because it prevents a whole class of bugs: by_ref(). A consumer like take or next consumes the iterator by value, so calling it.take(3) moves your iterator and you cannot continue with it. it.by_ref().take(3) borrows it instead, the chain stops after three pulls, and it remains usable for the rest of the work — the standard way to peek at the head of a sequence (process the first record, then loop over the remainder) without splitting state across two objects.

IntoIterator: Letting `for` Loops Own Your Type

for does not know about your type — it knows about three trait methods, and that is the whole integration surface:

// Desugared by the compiler:
for item in collection { ... }
// becomes:
let mut it = IntoIterator::into_iter(collection);
while let Some(item) = it.next() { ... }

Implement IntoIterator and your type works in every for, every collect destination pattern, and Iterator::chain in the std library. The complete integration for a container with three access modes:

struct Playlist {
    songs: Vec<Song>,
}

impl IntoIterator for Playlist {
    type Item = Song;
    type IntoIter = std::vec::IntoIter<Song>;

    fn into_iter(self) -> Self::IntoIter {      // consumes the playlist
        self.songs.into_iter()
    }
}

impl<'a> IntoIterator for &'a Playlist {
    type Item = &'a Song;
    type IntoIter = std::slice::Iter<'a, Song>;

    fn into_iter(self) -> Self::IntoIter { self.songs.iter() }   // borrows
}

impl<'a> IntoIterator for &'a mut Playlist {
    type Item = &'a mut Song;
    type IntoIter = std::slice::IterMut<'a, Song>;

    fn into_iter(self) -> Self::IntoIter { self.songs.iter_mut() }
}

Three impls, three call sites, all spelled the same way at the use site — and the ownership axis you have been following since Vec decides which one fires:

for s in &playlist { }            // &Song   — playlist still usable
for s in &mut playlist { play(s); } // &mut Song — mark as played in place
for s in playlist { archive(s); } // Song    — moves each song out, playlist consumed

The three impls light up the three loop forms, and seeing them side by side makes the ownership axis concrete:

let mut pl = Playlist::demo();

for s in &pl { report(s); }             // IntoIterator for &Playlist  -> &Song
for s in &mut pl { s.played = true; }   // IntoIterator for &mut Playlist -> &mut Song
for s in pl { archive(s); }             // IntoIterator for Playlist   -> Song (pl is moved)

Common implementation mistakes, so you can spot them in review: forgetting type IntoIter (the compiler asks for the concrete iterator type — name the inner one, e.g. std::vec::IntoIter<Song>), writing fn into_iter(self) -> ... that clones internally (the owned impl should move self.songs.into_iter() — a clone there silently doubles your cost and defeats the purpose of ownership transfer), and implementing only the owned form, which makes for x in &your_type fail with "&Playlist is not an iterator" while every std container supports it.

Delegation to the inner Vec is the standard implementation: you write self.songs.into_iter() and inherit correct behavior, bounds, and performance. There is no reason to hand-roll a next loop for storage you already keep in a Vec.

Design notes that come up beyond the happy path: the type Item can differ per impl (owned vs borrowed is the whole point), for over an array by value yields owned elements since edition 2021 (before that it implicitly borrowed), and implementing FromIterator<Song> for Playlist gives you songs.iter().collect::<Playlist>() — your type as both a source (IntoIterator) and a destination (FromIterator) of pipelines. Keep them symmetric: a container that can be iterated but not collected into feels wrong to everyone who uses it.

Interview framing: "how does for work?" — the compiler calls IntoIterator::into_iter once, then loops on next() until None; your type participates by implementing one trait, with the three-impl pattern (T, &T, &mut T) covering owned and borrowed iteration. From here the roadmap leaves collections for structure: Modules and Visibility decides where all this code lives.

Quiz

1. What must a type provide to gain every std adapter (`map`, `filter`, `collect`, `sum`)?

Question 1 options

2. What should `size_hint` return for a countdown with `remaining = 5`?

Question 2 options

3. Two-stage processing does `.filter(..).collect::<Vec<_>>().iter().map(..).collect()`. What is the cost relative to one chained pipeline?

Question 3 options

4. Your struct compiles but a `for s in playlist` loop gives `Song` by value and a later use of `playlist` errors. What happened?

Question 4 options

Flashcards

Question

What is the exact contract of `Iterator`, and what does `None` from `next` mean?

Answer

`type Item` plus `fn next(&mut self) -> Option<Self::Item>`. `Some(v)` yields the next value; `None` signals exhaustion, and after `None` a correct iterator keeps returning `None` (the fused guarantee adapters rely on).

Question

What does `size_hint` provide and what are its correctness rules?

Answer

`(lower_bound, Option<upper_bound>)` for remaining items. The lower bound must never be too high; the upper may be `None`. Exact hints let `collect` pre-allocate a `Vec` with `with_capacity`, eliminating growth reallocations.

Question

Why does an unused `iter().map(f).filter(p)` chain cost nothing, and how does it compare to a manual loop?

Answer

Adapters are lazy structs whose `next` calls the inner iterator — no calls happen without a consumer. Generics are monomorphized and inlined, so the compiled chain is typically one fused loop with no allocation or vtable, matching or beating a hand-written loop.

Question

When is an intermediate `collect` a mistake, and when is it required?

Answer

Mistake when it just feeds the next adapter (extra allocation + a second traversal; chain instead). Required when the value must outlive the borrow, needs mutation while iterating, crosses a function boundary, or must be `len()`/indexed.

Question

How does `for` integrate with a custom type, and what three impls do standard containers provide?

Answer

`for x in c` desugars to `IntoIterator::into_iter(c)` then `while let Some(x) = it.next()`. Containers implement `IntoIterator` for the owned type (yields `T`, consumes), `&T` (yields `&T`), and `&mut T` (yields `&mut T`) — choose by whether you move, read, or edit the elements.

Revision Notes

Key Takeaways

  • 1. Implementing `Iterator` requires only `type Item` and `next() -> Option<Item>`; every std adapter is written against that contract and comes for free
  • 2. `size_hint` (exact or lower-bound), `FusedIterator`, and `ExactSizeIterator` are performance claims you may override — the compiler cannot verify them, so only assert what is true
  • 3. Laziness plus monomorphization means a chain compiles to one fused loop with no allocation — the reason an intermediate `collect` (the real cost: extra allocation + second pass) is the main efficiency mistake
  • 4. Return pipelines as `impl Iterator` for static dispatch; use `Box<dyn Iterator>` only when the chain itself varies at runtime
  • 5. `for` desugars to `IntoIterator::into_iter` + `next` loop; provide the owned, `&T`, and `&mut T` impls so all three loop forms work on your type

Interview Tips

  • • "\"Implement an iterator for Fibonacci\" — a struct with two state fields, `next` computing the following value, then note `take(10).collect()` works for free; add `size_hint` → `(0, None)` for full marks"
  • • "\"Are iterators fast?\" — generic adapters are monomorphized and inlined into one fused loop, no vtable, no allocation; mention that the common slowdown is an intermediate `collect`, not the adapters"
  • • "\"What does `for` compile to?\" — `IntoIterator::into_iter(x)` then `while let Some(v) = it.next()`; then the three-impl pattern for owned/borrowed/mutable iteration on your own type"
  • • "\"How do you return an iterator from a function?\" — `-> impl Iterator<Item = T> + '_` keeps zero-cost static dispatch; `Box<dyn Iterator + '_>` when branches build different chains (one allocation, dynamic dispatch)"

Cheat Sheet

Custom Iterators and Laziness

Implement:

struct Counter(u8);

impl Iterator for Counter {
    type Item = u8;
    fn next(&mut self) -> Option<u8> {
        (self.0 > 0).then(|| { self.0 -= 1; self.0 })
    }
    fn size_hint(&self) -> (usize, Option<usize>) { (self.0 as usize, Some(self.0 as usize)) }
}

impl std::iter::FusedIterator for Counter {}   // only if true: None stays None

One next unlocks map, filter, collect, sum, zip, take — all adapters are generic over Iterator.

Laziness & cost:

let v = data.iter().filter(p).map(f).collect();   // one pass, one alloc (result)
// A collect() in the middle = extra alloc + full second traversal
// Debug probe without breaking the chain: .inspect(|x| println!("{x:?}"))

Release builds compile chains to a single fused loop — static dispatch, closures inlined, no boxing.

Return a pipeline from a function:

fn failing(rs: &[Req]) -> impl Iterator<Item = &Req> + '_ { rs.iter().filter(|r| r.status >= 500) }
// different chains per branch -> Box<dyn Iterator<Item = _> + '_>

for-loop integration:

impl IntoIterator for Playlist { type Item = Song; type IntoIter = vec::IntoIter<Song>; ... }   // consumes
impl<'a> IntoIterator for &'a Playlist  { Item = &'a Song;  ... iter() }     // borrows
impl<'a> IntoIterator for &'a mut Playlist { Item = &'a mut Song; ... iter_mut() }
// for x in c  =>  while let Some(x) = IntoIterator::into_iter(c).next()

Continue Learning

Before You Start

Related Reading