The Assembly Line of Data
You have a log of server events. Some are errors, some are warnings, some are debug noise. You need to find the errors, extract the timestamp and message, and build a summary report. In Python, you'd write a loop with an if and an append. In Rust, you write a chain. The chain reads like a sentence: "Take the events, keep only the errors, map them to a summary struct, and collect the result."
Chaining iterators is the standard way to process sequences in Rust. You start with a collection, attach adapter methods to transform or filter the data, and finish with a consumer method to produce a result. The chain is lazy, efficient, and expressive. It avoids intermediate allocations and lets the compiler optimize the loop down to bare metal performance.
How Chains Work: The Lazy Factory
Think of an iterator chain as a factory assembly line. You don't build the whole product upfront. You set up the stations. Station one checks if a part is defective. Station two paints the good parts. Station three boxes them. Nothing happens until you pull the lever at the end. That lever is the "consumer."
The chain is lazy. It processes one item at a time, passing it down the line. When the consumer asks for the next item, the request ripples backward through the chain. The last adapter asks the previous one, which asks the one before it, until the source iterator yields a value. The value flows forward, getting transformed or filtered at each step. If a filter rejects an item, the later adapters never see it. The work is skipped.
This pull model saves memory. You never create a temporary vector of filtered items. You never create a temporary vector of mapped items. Each adapter is a small struct that holds the state for its step. The chain holds the entire pipeline in a few bytes of stack memory.
Minimal Example
fn main() {
// Start with a collection.
let numbers = vec![1, 2, 3, 4, 5];
// Chain adapters. Each returns a new iterator type.
// The chain is lazy: nothing runs until `collect` is called.
let result: Vec<i32> = numbers
.iter() // Borrow each element. Yields &i32.
.filter(|&&x| x % 2 == 0) // Keep only even numbers.
.map(|&x| x * 2) // Double the remaining values.
.collect(); // Consume the iterator and build a Vec.
println!("{:?}", result); // [4, 8]
}
The chain does nothing until you pull the lever.
Under the Hood: Structs, Not Vectors
When you call .iter(), you get an iterator struct. When you call .filter(), you get a different struct that wraps the previous iterator and holds the closure. When you call .map(), you get yet another struct. These structs implement the Iterator trait. They don't store data. They store logic.
At runtime, collect calls next() on the Map iterator. Map calls next() on Filter. Filter calls next() on the slice iterator. The slice iterator yields &1. Filter checks 1 % 2 == 0. It returns None. Map returns None. Collect asks again. This repeats until Filter yields &2. Map doubles it to 4. Collect pushes 4 into the vector.
The compiler monomorphizes this code. It generates a specialized loop for your exact types. There is no virtual dispatch. There is no overhead. The generated assembly is often identical to a hand-written loop with manual indexing. You get the abstraction for free.
Trust the laziness. It saves memory you didn't know you were wasting.
Realistic Example: Parsing and Filtering
Chains shine when you combine multiple steps. Here's a realistic scenario: parsing a list of log lines, skipping malformed entries, and extracting scores.
#[derive(Debug)]
struct LogEntry {
level: String,
message: String,
}
fn summarize_errors(entries: &[LogEntry]) -> Vec<String> {
// Filter for errors, transform to a readable string, collect.
entries
.iter()
.filter(|entry| entry.level == "ERROR")
.map(|entry| format!("[{}] {}", entry.level, entry.message))
.collect()
}
fn parse_scores(lines: &[&str]) -> Vec<u32> {
// Parse strings to numbers, skip failures, filter by threshold.
lines
.iter()
.filter_map(|line| line.trim().parse().ok()) // Skip bad lines.
.filter(|&score| score > 50) // Keep high scores.
.collect()
}
fn count_long_words(paragraphs: &[String]) -> usize {
// Flatten nested words, filter by length, count.
paragraphs
.iter()
.flat_map(|p| p.split_whitespace()) // Flatten words.
.filter(|word| word.len() > 3) // Filter short words.
.count() // Consumer.
}
Read the chain left-to-right. If it reads like a sentence, you're doing it right.
Pitfalls: Borrowing and Ambiguity
Chains interact with the borrow checker. Closures capture variables by reference by default. If you try to move a value into a chain, the compiler stops you.
let data = vec![String::from("a"), String::from("b")];
// Error: cannot move out of `*item` which is behind a shared reference
// let bad: Vec<String> = data.iter().map(|item| item.clone()).collect();
// Fix: use `.into_iter()` if you want to move, or `.iter().cloned()`.
let good: Vec<String> = data.iter().cloned().collect();
The error here is E0507 (cannot move out of borrowed content). .iter() yields &String. You can't move the String out of the reference. You can clone it, or you can use .into_iter() to take ownership of the vector and yield String values.
Another common issue is collect ambiguity. collect is generic. It can build a Vec, a HashSet, a HashMap, or even a String. If the compiler can't guess, it errors with E0283 (type annotations needed).
let numbers = vec![1, 2, 3];
// Error: type annotations needed
// let result = numbers.iter().collect();
// Fix: annotate the binding.
let result: Vec<&i32> = numbers.iter().collect();
// Alternative: turbofish syntax.
let result = numbers.iter().collect::<Vec<&i32>>();
The convention is to annotate the binding. It's cleaner than the turbofish and keeps the chain readable.
The compiler is your partner here. Fix the borrow, and the chain flows.
Choosing Your Iterator Method
The method you call on the collection determines what the iterator yields.
iter() borrows the collection. It yields &T. Use this when you only need to read the data. It's the most common choice.
into_iter() consumes the collection. It yields T. Use this when you need to move the values out of the collection. The collection is no longer usable after this call.
iter_mut() borrows the collection mutably. It yields &mut T. Use this when you need to modify the elements in place.
let mut data = vec![1, 2, 3];
// Modify in place.
data.iter_mut().for_each(|x| *x *= 2);
// data is now [2, 4, 6].
// Move out.
let moved: Vec<i32> = data.into_iter().collect();
// data is moved. Cannot use data here.
Convention aside: prefer cloned() over map(|x| x.clone()). Both work, but cloned() is explicit about the intent and slightly more idiomatic. It signals that you are cloning references to get owned values.
Decision: When to Chain vs Loop
Use a for loop when you need side effects like printing or mutating external state, and the logic is too complex for a clean chain.
Use a chain when you are transforming a collection into another collection, or computing a single value like a sum or count.
Use filter_map when you are filtering and mapping in the same step, like parsing a string that might fail. It's cleaner than chaining filter and map separately.
Use try_fold or try_for_each when you need to short-circuit on an error. Standard chains keep going until the end; these stop at the first Err.
Use for_each when the goal is purely side effects and you want to signal that explicitly, though a standard for loop is often preferred for readability in that case.
Use collect when you need to materialize the result into a collection.
Use count, sum, or any when you need a single scalar result and don't need the intermediate data.
Pick the tool that matches the shape of your data.