Using Iterators in Rust Programming Language: Lazy Evaluation, Adapters, and Consumers Explained

Using Iterators in Rust

Using Iterators in Rust

I remember the first time I chained five .map() and .filter() calls in Rust and expected my terminal to lag from all that “processing.” It didn’t. Nothing happened at all — until I called .collect() at the end, and suddenly the whole pipeline ran in one pass. That was my introduction to lazy evaluation, and it’s the single idea that makes Rust’s iterator system feel less like a library feature and more like a part of the language itself.

In this article, I want to walk through how iterators actually work under the hood in Rust, why laziness matters for performance, the difference between adapters and consumers, and how all of this ties back into ownership and zero-cost abstractions.

What Is an Iterator, Really?

At its core, an iterator in Rust is just a type that implements one trait:

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

That’s it. One associated type, one method. Everything else — map, filter, fold, sum, collect, dozens of others — is a default method built on top of next(). This is why implementing Iterator for your own type gives you an entire toolbox for free.

struct Countdown(u32);

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<u32> {
        if self.0 == 0 {
            None
        } else {
            self.0 -= 1;
            Some(self.0 + 1)
        }
    }
}

fn main() {
    let countdown = Countdown(5);
    for n in countdown {
        println!("{}", n);
    }
}

Output:

5
4
3
2
1

Lazy Evaluation: Nothing Runs Until You Ask

This is the part that surprised me most. Consider:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    let pipeline = numbers.iter()
        .map(|x| {
            println!("mapping {}", x);
            x * 2
        })
        .filter(|x| {
            println!("filtering {}", x);
            x % 3 == 0
        });

    println!("Pipeline built. Nothing has run yet.");

    let result: Vec<i32> = pipeline.collect();
    println!("{:?}", result);
}

Output:

Pipeline built. Nothing has run yet.
mapping 1
filtering 2
mapping 2
filtering 4
mapping 3
filtering 6
mapping 4
filtering 8
mapping 5
filtering 10
[6]

Notice how map and filter interleave per element, rather than running fully separately. Each element flows through the entire chain before the next element starts. This is lazy evaluation in action: calling .map() or .filter() doesn’t loop over anything — it just wraps the previous iterator in a new struct that knows how to produce the next transformed value when asked. Nothing executes until something consumes the iterator, like .collect().

Why This Matters for Performance

Because adapters are lazy and generic over the underlying iterator type, the Rust compiler can often inline the entire chain into a single tight loop with no intermediate allocations. This is what “zero-cost abstraction” means in practice — the high-level .map().filter().sum() chain compiles down to roughly the same machine code as a hand-written for loop with manual if checks. I’ve genuinely checked this with cargo asm on small examples, and the generated code is nearly identical to the imperative version.

Adapters vs. Consumers

I think of iterator methods in two buckets:

fn main() {
    let words = vec!["rust", "is", "fun", "and", "fast"];

    // Adapters: build a lazy pipeline
    let long_words = words.iter()
        .filter(|w| w.len() > 2)
        .map(|w| w.to_uppercase());

    // Consumer: actually runs it
    let result: Vec<String> = long_words.collect();
    println!("{:?}", result);
}

Output:

["RUST", "FUN", "AND", "FAST"]

Until .collect() (a consumer) is called, long_words is just a description of work to be done, not the work itself.

Common Adapters I Use Constantly

fn main() {
    let nums = vec![1, 2, 3, 4, 5, 6];

    // enumerate: pair each item with its index
    for (i, n) in nums.iter().enumerate() {
        println!("index {}: {}", i, n);
    }

    // zip: combine two iterators pairwise
    let letters = vec!['a', 'b', 'c'];
    let zipped: Vec<(i32, char)> = nums.iter().cloned().zip(letters).collect();
    println!("{:?}", zipped);

    // take / skip
    let first_three: Vec<&i32> = nums.iter().take(3).collect();
    let after_three: Vec<&i32> = nums.iter().skip(3).collect();
    println!("{:?} / {:?}", first_three, after_three);
}

Output:

index 0: 1
index 1: 2
index 2: 3
index 3: 4
index 4: 5
index 5: 6
[(1, 'a'), (2, 'b'), (3, 'c')]
[1, 2, 3] / [4, 5, 6]

Common Consumers I Use Constantly

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    let total: i32 = nums.iter().sum();
    let product: i32 = nums.iter().product();
    let max = nums.iter().max();
    let found = nums.iter().find(|&&x| x > 3);

    println!("sum: {}, product: {}, max: {:?}, first > 3: {:?}", total, product, max, found);

    // fold: the most general consumer — build any accumulated value
    let joined = nums.iter().fold(String::new(), |mut acc, n| {
        acc.push_str(&n.to_string());
        acc.push('-');
        acc
    });
    println!("{}", joined);
}

Output:

sum: 15, product: 120, max: Some(5), first > 3: Some(4)
1-2-3-4-5-

fold deserves special mention — nearly every other consumer (sum, count, max) can be expressed in terms of fold. Once I understood fold, the rest of the consumer methods felt like convenient named shortcuts rather than separate concepts to memorize.

Ownership: iter(), into_iter(), and iter_mut()

This tripped me up constantly as a beginner, so it’s worth being explicit:

fn main() {
    let v = vec![1, 2, 3];

    // iter(): borrows each element as &T
    for x in v.iter() {
        println!("borrowed: {}", x);
    }

    // iter_mut(): borrows each element as &mut T
    let mut v2 = vec![1, 2, 3];
    for x in v2.iter_mut() {
        *x *= 10;
    }
    println!("{:?}", v2);

    // into_iter(): takes ownership, yields T
    for x in v.into_iter() {
        println!("owned: {}", x);
    }
    // v is no longer usable here — it was moved
}

Output:

borrowed: 1
borrowed: 2
borrowed: 3
[10, 20, 30]
owned: 1
owned: 2
owned: 3

for x in &v is sugar for v.iter(), and for x in v is sugar for v.into_iter(). Once that clicked, most of my confusion about “why won’t this compile, I just used v in a loop” disappeared — the loop had consumed v by value.

A Real-World Example: Processing Log Lines

fn main() {
    let log = "\
2024-01-01 INFO server started
2024-01-01 ERROR failed to bind port
2024-01-02 INFO connection accepted
2024-01-02 ERROR timeout on request";

    let error_count = log
        .lines()
        .filter(|line| line.contains("ERROR"))
        .count();

    let error_messages: Vec<&str> = log
        .lines()
        .filter(|line| line.contains("ERROR"))
        .map(|line| line.splitn(3, ' ').last().unwrap())
        .collect();

    println!("Total errors: {}", error_count);
    println!("Messages: {:?}", error_messages);
}

Output:

Total errors: 2
Messages: ["failed to bind port", "timeout on request"]

This is the pattern I use daily — filtering and transforming text data with an iterator chain instead of manual loops with mutable accumulator variables. It reads almost like a description of intent rather than a set of steps.

Best Practices and Idiomatic Patterns

Common Mistakes

  1. Forgetting iterators are lazy and expecting a .map() call alone to “do work” — it won’t do anything until consumed.
  2. Calling .collect() too eagerly, turning what could be one pass into several passes with intermediate allocations.
  3. Confusing iter() and into_iter(), leading to “value moved” compiler errors that seem to appear out of nowhere.
  4. Using .unwrap() on .find() or .max() results without considering the None case for an empty collection.

FAQs and Troubleshooting

Q: Why doesn’t my iterator chain print anything? A: You probably didn’t call a consumer. .map() and .filter() alone build a lazy pipeline; add .collect(), .for_each(), or a for loop to actually run it.

Q: What’s the difference between .iter() and .into_iter() on a Vec? A: .iter() yields references (&T) and leaves the vector usable afterward. .into_iter() consumes the vector and yields owned values (T).

Q: Are iterator chains actually as fast as a for loop? A: In release builds, yes — almost always. The compiler aggressively inlines and optimizes iterator chains into equivalent machine code as hand-written loops, thanks to Rust’s zero-cost abstraction guarantees. Always benchmark with --release; debug builds don’t inline as aggressively.

Q: How do I write my own custom iterator adapter? A: Define a struct wrapping the inner iterator, implement Iterator for it, and have next() call the inner iterator’s next() while applying your transformation.

Summary

Rust’s iterator system is where I finally understood what “zero-cost abstraction” means in practice: the Iterator trait boils down to a single next() method, adapters lazily wrap one iterator inside another, and nothing actually runs until a consumer pulls values through the chain. Once that mental model settles in, iterator chains stop feeling like magic and start feeling like the most natural way to express data transformations in Rust — often compiling to code just as fast as, or faster than, a manually written loop.

References

Exit mobile version