Rust Iterators: Iterator Trait and Adapters

The `Iterator` trait is Rust's abstraction for sequential data processing. At its core, the trait requires implementing a single method: `next()`, which returns `Option<Self::Item>`. The `Item`...

Key Insights

  • Rust iterators are zero-cost abstractions that compile to the same assembly as hand-written loops while providing composable, functional-style data processing
  • The three iterator creation methods (iter(), iter_mut(), into_iter()) differ in ownership semantics—understanding these differences prevents common borrowing errors
  • Iterator adapters are lazy and only execute when consumed by a terminal operation, enabling efficient pipeline construction without intermediate allocations

Introduction to the Iterator Trait

The Iterator trait is Rust’s abstraction for sequential data processing. At its core, the trait requires implementing a single method: next(), which returns Option<Self::Item>. The Item associated type specifies what the iterator produces, while next() returns Some(item) until exhausted, then None indefinitely.

Iterators maintain internal state to track position. Each next() call mutates this state, advancing the iterator forward. This design enables lazy evaluation—computation happens only when you pull values, not when you construct the iterator.

Here’s a custom iterator implementation:

struct CountDown {
    current: u32,
}

impl CountDown {
    fn new(start: u32) -> Self {
        CountDown { current: start }
    }
}

impl Iterator for CountDown {
    type Item = u32;
    
    fn next(&mut self) -> Option<Self::Item> {
        if self.current == 0 {
            None
        } else {
            let result = self.current;
            self.current -= 1;
            Some(result)
        }
    }
}

// Usage
let mut countdown = CountDown::new(3);
assert_eq!(countdown.next(), Some(3));
assert_eq!(countdown.next(), Some(2));
assert_eq!(countdown.next(), Some(1));
assert_eq!(countdown.next(), None);

Once you implement Iterator, you automatically get access to dozens of adapter methods provided by the trait’s default implementations.

Creating Iterators from Collections

Rust collections provide three methods for creating iterators, each with different ownership semantics:

  • iter() - Borrows items immutably (&T)
  • iter_mut() - Borrows items mutably (&mut T)
  • into_iter() - Takes ownership and consumes the collection (T)

Understanding these distinctions is critical for writing correct Rust code:

let numbers = vec![1, 2, 3, 4, 5];

// iter() - borrows immutably
for n in numbers.iter() {
    println!("{}", n); // n is &i32
}
// numbers still available here

// iter_mut() - borrows mutably
let mut numbers = vec![1, 2, 3, 4, 5];
for n in numbers.iter_mut() {
    *n *= 2; // n is &mut i32
}
// numbers modified but still owned

// into_iter() - consumes the collection
let numbers = vec![1, 2, 3, 4, 5];
for n in numbers.into_iter() {
    println!("{}", n); // n is i32
}
// numbers no longer available - moved

The IntoIterator trait enables the for loop syntax. When you write for item in collection, Rust calls collection.into_iter(). Collections implement IntoIterator for owned values, shared references, and mutable references, providing appropriate iterator types for each case.

Common Iterator Adapters

Iterator adapters transform iterators into new iterators. They’re lazy—calling an adapter doesn’t perform any work. The computation only happens when a consumer pulls values through the chain.

The most common adapters are map() for transformation, filter() for selection, and flat_map() for flattening:

#[derive(Debug)]
struct Transaction {
    amount: f64,
    category: String,
    approved: bool,
}

let transactions = vec![
    Transaction { amount: 100.0, category: "Food".into(), approved: true },
    Transaction { amount: 250.0, category: "Travel".into(), approved: false },
    Transaction { amount: 50.0, category: "Food".into(), approved: true },
    Transaction { amount: 500.0, category: "Travel".into(), approved: true },
];

let approved_food_total: f64 = transactions
    .iter()
    .filter(|t| t.approved)
    .filter(|t| t.category == "Food")
    .map(|t| t.amount)
    .sum();

println!("Approved food spending: ${}", approved_food_total); // $150.0

This chain creates no intermediate collections. The compiler fuses these operations into a single pass over the data, equivalent to a hand-written loop but more expressive.

Consuming Iterators

Terminal operations (consumers) actually execute the iterator chain. Until you call a consumer, no iteration occurs.

Common consumers include:

  • collect() - Builds a collection from iterator items
  • fold() - Reduces iterator to single value with accumulator
  • sum(), count(), min(), max() - Specialized reductions
  • find(), any(), all() - Short-circuiting searches
  • for_each() - Executes side effects

The collect() method is particularly powerful, using type inference to build various collection types:

use std::collections::HashMap;

let words = vec!["apple", "banana", "cherry", "apricot", "blueberry"];

// Collect into HashMap with first letter as key, word length as value
let length_map: HashMap<char, Vec<usize>> = words
    .iter()
    .map(|word| (word.chars().next().unwrap(), word.len()))
    .fold(HashMap::new(), |mut acc, (letter, len)| {
        acc.entry(letter).or_insert_with(Vec::new).push(len);
        acc
    });

println!("{:?}", length_map);
// {'a': [5, 7], 'b': [6, 9], 'c': [6]}

The fold() consumer is especially versatile, allowing you to implement most other consumers in terms of it. It threads an accumulator through each iteration, building up a final result.

Advanced Patterns

Beyond basic transformations, Rust provides adapters for sophisticated iteration patterns.

enumerate() pairs each item with its index. zip() combines two iterators element-wise. take() and skip() control how many items to process:

let log_lines = vec![
    "2024-01-15 INFO Server started",
    "2024-01-15 DEBUG Connection established",
    "2024-01-15 ERROR Failed to parse request",
    "2024-01-15 INFO Request processed",
    "2024-01-15 ERROR Database timeout",
];

// Parse error lines with line numbers
let errors: Vec<(usize, String)> = log_lines
    .iter()
    .enumerate()
    .filter_map(|(idx, line)| {
        if line.contains("ERROR") {
            // Extract error message after "ERROR "
            line.split("ERROR ")
                .nth(1)
                .map(|msg| (idx + 1, msg.to_string()))
        } else {
            None
        }
    })
    .collect();

for (line_num, error) in errors {
    println!("Line {}: {}", line_num, error);
}
// Line 3: Failed to parse request
// Line 5: Database timeout

The filter_map() adapter combines filtering and mapping in one step, which is more efficient than chaining filter() and map() when the mapping might fail.

scan() provides stateful transformations, maintaining an accumulator across iterations:

let deltas = vec![10, -5, 3, -8, 15];

// Running balance starting at 100
let balances: Vec<i32> = deltas
    .iter()
    .scan(100, |balance, &delta| {
        *balance += delta;
        Some(*balance)
    })
    .collect();

println!("{:?}", balances); // [110, 105, 108, 100, 115]

Performance Considerations

Rust iterators are zero-cost abstractions. The compiler optimizes iterator chains into the same machine code as manual loops. Here’s a comparison:

// Iterator version
fn sum_even_squares_iter(numbers: &[i32]) -> i32 {
    numbers
        .iter()
        .filter(|&&n| n % 2 == 0)
        .map(|&n| n * n)
        .sum()
}

// Manual loop version
fn sum_even_squares_loop(numbers: &[i32]) -> i32 {
    let mut sum = 0;
    for &n in numbers {
        if n % 2 == 0 {
            sum += n * n;
        }
    }
    sum
}

Both functions compile to nearly identical assembly. The iterator version benefits from iterator fusion—the compiler merges the filter and map operations into a single pass.

However, know when to collect intermediate results. If you need to iterate over the same data multiple times, calling collect() once is more efficient than rebuilding the iterator chain:

// Inefficient - rebuilds filtered list twice
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let even_sum: i32 = data.iter().filter(|&&x| x % 2 == 0).sum();
let even_product: i32 = data.iter().filter(|&&x| x % 2 == 0).product();

// Efficient - collect once, reuse
let evens: Vec<i32> = data.iter().filter(|&&x| x % 2 == 0).copied().collect();
let even_sum: i32 = evens.iter().sum();
let even_product: i32 = evens.iter().product();

Best Practices and Common Pitfalls

Implement custom iterators when you have a non-trivial sequence that doesn’t fit existing adapters. Don’t implement Iterator just to make a collection iterable—implement IntoIterator instead.

Avoid unnecessary collect() calls. This is a common mistake when transitioning from imperative languages:

// Inefficient - unnecessary intermediate collection
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers.iter().map(|&x| x * 2).collect();
let sum: i32 = doubled.iter().sum();

// Efficient - direct consumption
let numbers = vec![1, 2, 3, 4, 5];
let sum: i32 = numbers.iter().map(|&x| x * 2).sum();

When type inference fails with complex iterator chains, help the compiler by annotating intermediate types or the final collection type:

use std::collections::HashSet;

let numbers = vec![1, 2, 3, 2, 1];

// Explicit type annotation on collect
let unique: HashSet<i32> = numbers.into_iter().collect();

// Or use turbofish syntax
let unique = numbers.into_iter().collect::<HashSet<_>>();

Iterators transform Rust code from imperative loops into declarative pipelines. Master them, and you’ll write more expressive, maintainable, and performant code. The initial learning curve pays dividends in every Rust project you build.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.