Skip to content

Rust Iterators and Lazy Adapters

An iterator supplies items through next(); adapters such as map and filter describe how future items will be processed. Building a chain does not run its callbacks, and choosing iter(), iter_mut() or into_iter() determines what that particular collection lends or transfers.

Prerequisites and outcome

Complete closures and function pointers. You will track the item type through an adapter chain, choose when to consume it, implement a small Iterator, and preserve parsing failures during collection. All inputs are fixtures; no live sensor is used.

Rust Iterator is a standard-library trait

Iterator declares an associated Item type and fn next(&mut self) -> Option<Self::Item>. Our Countdown supplies owned u8 values and updates its state on each call. Its zero state returns None without subtracting, avoiding unsigned underflow.

The language's for loop uses IntoIterator to obtain an iterator, then repeatedly obtains items. Iterator and IntoIterator are library interfaces, while the loop syntax is a language construct. See iterator loops and IntoIterator.

Build the complete iterator program

cargo new pi_iterators
cd pi_iterators

Keep edition = "2024" in Cargo.toml and replace src/main.rs with:

use std::num::ParseIntError;

fn high_readings<'a>(readings: &'a [i32], threshold: i32) -> impl Iterator<Item = i32> + 'a {
    readings
        .iter()
        .copied()
        .filter(move |value| *value >= threshold)
}

fn parse_all(inputs: &[&str]) -> Result<Vec<i32>, ParseIntError> {
    inputs
        .iter()
        .map(|text| text.trim().parse::<i32>())
        .collect()
}

struct Countdown {
    remaining: u8,
}

impl Iterator for Countdown {
    type Item = u8;

    fn next(&mut self) -> Option<Self::Item> {
        if self.remaining == 0 {
            None
        } else {
            let value = self.remaining;
            self.remaining -= 1;
            Some(value)
        }
    }
}

fn main() {
    let mut readings = vec![46_700, 60_000, 0];
    let high: Vec<i32> = high_readings(&readings, 60_000).collect();
    println!("high={high:?}, original length={}", readings.len());

    for value in readings.iter_mut() {
        *value += 1;
    }
    println!("updated first={}", readings[0]);

    // The Vec is consumed here; the sum is widened for these fixtures.
    let total: i64 = readings.into_iter().map(i64::from).sum();
    println!("owned sum={total}");

    println!("strict={:?}", parse_all(&["46700", "0"]));
    println!(
        "invalid rejected={}",
        parse_all(&["46700", "bad", "0"]).is_err()
    );

    // A separate, explicit discard policy: invalid records are not reported.
    let valid_only: Vec<i32> = ["46700", "bad", "0"]
        .iter()
        .filter_map(|text| text.parse::<i32>().ok())
        .collect();
    println!("valid only={valid_only:?}");

    let countdown: Vec<u8> = Countdown { remaining: 3 }.collect();
    println!("countdown={countdown:?}");
}

#[cfg(test)]
mod tests {
    use super::{Countdown, high_readings, parse_all};

    #[test]
    fn filter_includes_boundary_and_preserves_zero() {
        let values = [-500, 0, 60_000];
        assert_eq!(
            high_readings(&values, 0).collect::<Vec<_>>(),
            vec![0, 60_000]
        );
        assert!(high_readings(&[], 0).collect::<Vec<_>>().is_empty());
    }

    #[test]
    fn borrowing_pipeline_leaves_the_owner_available() {
        let mut values = vec![0, 60_000];
        assert_eq!(high_readings(&values, 60_000).count(), 1);
        for value in values.iter_mut() {
            *value += 1;
        }
        assert_eq!(values, vec![1, 60_001]);
    }

    #[test]
    fn owned_items_can_include_non_copy_strings() {
        let labels = vec![String::from("Pi"), String::from("Pi 4B")];
        let lengths: Vec<usize> = labels.into_iter().map(|label| label.len()).collect();
        assert_eq!(lengths, vec![2, 5]);
    }

    #[test]
    fn unconsumed_map_never_runs_its_callback() {
        let mut visited = Vec::new();
        {
            let pipeline = [1, 2, 3].into_iter().map(|value| {
                visited.push(value);
                value + 1
            });
            drop(pipeline);
        }
        assert!(visited.is_empty());
    }

    #[test]
    fn take_requests_only_the_required_items() {
        let mut visited = Vec::new();
        let output: Vec<i32> = [1, 2, 3]
            .into_iter()
            .map(|value| {
                visited.push(value);
                value + 1
            })
            .take(1)
            .collect();
        assert_eq!(output, vec![2]);
        assert_eq!(visited, vec![1]);
    }

    #[test]
    fn result_collection_preserves_empty_and_valid_input() {
        assert_eq!(
            parse_all(&[]).expect("valid empty fixture"),
            Vec::<i32>::new()
        );
        assert_eq!(
            parse_all(&[" -500 ", "0"]).expect("valid fixture"),
            vec![-500, 0]
        );
        assert!(parse_all(&["bad"]).is_err());
        assert!(parse_all(&["2147483648"]).is_err());
    }

    #[test]
    fn result_collection_stops_at_the_first_error() {
        let mut visited = Vec::new();
        let result: Result<Vec<i32>, _> = ["1", "bad", "2"]
            .into_iter()
            .map(|text| {
                visited.push(text);
                text.parse::<i32>()
            })
            .collect();
        assert!(result.is_err());
        assert_eq!(visited, vec!["1", "bad"]);
    }

    #[test]
    fn custom_iterator_handles_zero_and_exhaustion() {
        let mut empty = Countdown { remaining: 0 };
        assert_eq!(empty.next(), None);
        let mut one = Countdown { remaining: 1 };
        assert_eq!(one.next(), Some(1));
        assert_eq!(one.next(), None);
        assert_eq!(one.next(), None);
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
5
6
7
high=[60000], original length=3
updated first=46701
owned sum=106703
strict=Ok([46700, 0])
invalid rejected=true
valid only=[46700, 0]
countdown=[3, 2, 1]

There are eight tests. The lazy tests record which inputs were visited without timing measurements or interior mutability. Dropping a map pipeline releases its captures without calling its mapping closure.

Track the item type, not just the method name

Expression on our Vec Item Effect on collection ownership
readings.iter() &i32 Shared borrow
readings.iter_mut() &mut i32 Exclusive borrow
readings.into_iter() i32 Consumes the Vec
readings.iter().copied() i32 Copies each integer, preserving the Vec

These rows describe Vec, not a universal rule that every into_iter call consumes backing data. References also implement IntoIterator; for value in &readings borrows through that implementation. For non-Copy owned elements such as String, into_iter can transfer each element; copied() is not available for String.

In high_readings, copied changes the item from &i32 to i32. filter then passes a shared reference to that item into its predicate, explaining the dereference in |value| *value >= threshold. The opaque iterator borrows readings, while its move closure owns a copy of threshold; move does not make the borrowed slice static.

Lazy adapters still take ownership of iterator state

map, filter and take return iterator adapters whose callbacks run when items are requested. collect, sum and count consume an iterator to produce a result; a for loop also requests items. Creating an unused map does not perform a transformation, even if the closure contains visible side effects. See the Book's iterator progression.

Laziness does not mean “no ownership change”. An adapter may take its input iterator by value while deferring processing of items. sum consumes its receiver too, so reusing that same iterator binding afterward can be a move error. Building an iterator can also evaluate arguments or move captures immediately; only the deferred item processing is lazy.

collect's target determines the result type. We annotate Vec or use collect::<Vec<_>>(); the underscore asks for an inferred element type. Do not assume every collect returns a Vec or that all consumers visit every input: take limits demand, and short-circuiting consumers may stop early.

For advanced traversal, next advances state directly, by_ref lends an iterator to an adapter, and fold accumulates without necessarily allocating a result collection. Consult the Iterator API when choosing between transforming, borrowing and consuming methods.

Keep failure policy explicit

parse_all maps each input to Result, then collects into Result, ParseIntError>. A failure ends that collection and returns the error; the successful prefix is not returned as a partial Vec. Empty input successfully produces an empty vector. The Result FromIterator implementation defines this behaviour.

Using filter_map with Result::ok deliberately discards failed records. It does not count invalid inputs, distinguish missing values, or preserve their error details. That is a different policy, shown separately so it cannot silently replace the Part I report's accounting. For a report that must keep all errors, retain the Results or accumulate measured/missing/invalid categories explicitly.

Implementing next does not promise every optimisation

Countdown's Item is u8 and its state is a remaining count. Once it reaches zero, this particular implementation keeps returning None. Iterator in general permits a later Some after None; use fuse() or a suitable FusedIterator guarantee when repeated exhaustion matters.

An Iterator implementation does not automatically promise an exact remaining length, double-ended traversal, or a particular allocation strategy. Additional traits and size_hint contracts have their own requirements. Do not claim a custom iterator is faster than a loop merely because it has fewer source lines.

Deliberately failing: using a consumed Vec

In a separate scratch project, replace src/main.rs with:

1
2
3
4
5
fn main() {
    let readings = vec![46_700, 60_000, 0];
    let total: i32 = readings.into_iter().sum();
    println!("sum={total}, length={}", readings.len());
}

cargo check reports E0382 because into_iter consumes this Vec. To preserve it, use a borrowed iterator and copy the integer items, for example readings.iter().copied().sum(). Do not clone the entire collection when a shared traversal is sufficient.

Exercises and troubleshooting

  1. Repair the consumed-Vec example with iter().copied(): expect sum=106700, length=3. Notice that these values were not incremented like the main program's readings.
  2. Remove the dereference in high_readings' filter predicate: expect E0308 because the predicate receives &i32 while threshold is i32.
  3. Store let values = readings.iter();, call values.sum::(), then try values.count(): expect E0382 because sum consumes the iterator, even though the Vec is still borrowed rather than consumed.
  4. Change parse_all to filter out unsuccessful parses and return Ok of the remaining values: the invalid-input tests must fail. Explain the lost error policy rather than updating the tests to accept missing records.
  5. Change Countdown's initial remaining value in main from 3 to 0: expect countdown=[]. Its zero/exhaustion tests should still pass.
  6. In the lazy take test, use take(2): expect output [2, 3] and visited [1, 2]. Construction alone should still visit no inputs.

If collect cannot infer a type, annotate its target. If a callback argument is a reference, follow the current Item and the adapter's signature. If a pipeline silently loses data, inspect filter_map, flatten and error conversion rather than assuming laziness is responsible.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all eight tests, formatting and debug/release output comparisons passed. Borrowed-sum repair, zero countdown and two-item demand variants also passed. Consumed Vec/iterator and incorrectly referenced filter arguments produced E0382 or E0308 as expected. Replacing strict parsing with error discarding compiled but failed the invalid-input test, confirming that policy change was detected. No performance or live sensor claim is made.

Next: tests, documentation and Cargo, including the Part II reusable-library checkpoint.

Previous: closures and function pointers · Course overview

Donate