Rust Iterators: laziness, consumption, fold, and collect
Rust iterators are easiest to understand if you split methods into two groups:
- adapters build a new lazy iterator
- consumers pull values through the iterator and do the work
That distinction explains laziness, single-pass behavior, and many common bugs.
Adapters vs Consumers
These are adapters:
let iter = [1, 2, 3, 4]
.iter()
.map(|n| n * 2)
.filter(|n| *n > 4);At this point, no numbers have been collected or printed.
Rust has only built a lazy pipeline.
These are consumers:
let total: i32 = [1, 2, 3, 4].iter().copied().sum();
let values: Vec<i32> = [1, 2, 3, 4].iter().map(|n| n * 2).collect();
let product = [1, 2, 3, 4].iter().fold(1, |acc, n| acc * *n);Methods like sum(), collect(), fold(), count(), find(), and for_each() consume the iterator.
Laziness Means “Describe First, Run Later”
This is lazy:
let pipeline = ["10", "20", "30"]
.iter()
.map(|text| text.parse::<i32>().unwrap())
.filter(|n| *n >= 20);The parsing and filtering happen only when some consumer asks for values.
let numbers: Vec<i32> = pipeline.collect();That is when the work happens.
Iterators Are Usually Single-Pass
An iterator is not a reusable container. It is a walk through data.
Once items have been pulled out, they are gone from that iterator.
let mut iter = [10, 20, 30].into_iter();
assert_eq!(iter.next(), Some(10));
assert_eq!(iter.next(), Some(20));
assert_eq!(iter.next(), Some(30));
assert_eq!(iter.next(), None);That same idea causes real bugs with split() iterators.
The Exhausted split('+') Bug
This looks reasonable at first glance:
let expr = "10+20+30";
let mut parts = expr.split('+');
let valid = parts.all(|part| !part.trim().is_empty());
let total: i32 = parts
.map(|part| part.trim().parse::<i32>().unwrap())
.sum();
println!("valid = {valid}, total = {total}");But all() is a consumer.
It already walked the iterator.
So by the time sum() runs, parts is exhausted and total becomes 0.
The bug is not in split() itself. The bug is assuming an iterator can be reused like a stored list.
The same idea appears in more realistic code with try_fold():
let mut left_split = left.split('+');
let left_sum: Result<usize, MyError> =
left_split.try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));
let left_no_leading_zero =
left_split.all(|word| !is_leading_zero(map, word));The problem is the same.
left_split is one iterator value.
After try_fold(...), that iterator has already been advanced to the end.
So when all(...) runs:
- there are no words left
- the closure is never called
is_leading_zero(map, word)never runsall(...)returnstrue
That last point surprises many people at first.
all(...) returns true on an empty iterator because there was no item that violated the predicate.
That is also why a dbg!() inside the closure never prints. The closure is not “failing to print.” It is never entered.
One fix is to split twice when each pass is cheap and clear:
fn is_valid(map: &HashMap<char, u8>, left: &str, right: &str) -> Result<bool, MyError> {
let left_sum: Result<usize, MyError> =
left.split('+')
.try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));
let left_no_leading_zero =
left.split('+').all(|word| !is_leading_zero(map, word));
let right_sum: Result<usize, MyError> =
right.split('+')
.try_fold(0, |acc, word| Ok(acc + word_to_int(map, word)?));
let right_no_leading_zero =
right.split('+').all(|word| !is_leading_zero(map, word));
Ok(left_no_leading_zero && right_no_leading_zero && left_sum? == right_sum?)
}Another fix is to collect once if you need multiple passes over the same pieces:
fn is_valid(map: &HashMap<char, u8>, left: &str, right: &str) -> Result<bool, MyError> {
let left_words: Vec<&str> = left.split('+').collect();
let right_words: Vec<&str> = right.split('+').collect();
let left_sum = left_words
.iter()
.try_fold(0usize, |acc, word| Ok(acc + word_to_int(map, word)?));
let right_sum = right_words
.iter()
.try_fold(0usize, |acc, word| Ok(acc + word_to_int(map, word)?));
let left_no_leading_zero = left_words.iter().all(|word| !is_leading_zero(map, word));
let right_no_leading_zero = right_words.iter().all(|word| !is_leading_zero(map, word));
Ok(left_no_leading_zero && right_no_leading_zero && left_sum? == right_sum?)
}Now the reusable container is Vec<&str>, not the iterator.
sum() Is Great for “Add Everything”
Use sum() when the intent is just accumulation.
let total: i32 = [1, 2, 3, 4].iter().copied().sum();The type annotation matters.
Without enough type information, Rust may not know what numeric type you want.
That is why these are common:
let total: i64 = [1, 2, 3, 4].iter().copied().sum();
let total = [1_i64, 2, 3, 4].iter().copied().sum::<i64>();collect() Needs a Destination Type
collect() is flexible, which means Rust often needs help deciding what to build.
let words: Vec<&str> = "red,green,blue".split(',').collect();Or:
let words = "red,green,blue"
.split(',')
.collect::<Vec<&str>>();Same pipeline, same values, different style.
The important idea is that collect() consumes the iterator and builds some collection type.
fold() Lets You Control the Accumulator
fold() is the most general “reduce this iterator into one value” tool.
let total = [1, 2, 3, 4]
.iter()
.fold(0, |acc, n| acc + *n);The accumulator type starts from the initial value.
Here, 0 makes acc an integer accumulator.
You can make that more explicit when needed:
let total = [1, 2, 3, 4]
.iter()
.fold(0_i64, |acc, n| acc + i64::from(*n));Now the accumulator is clearly i64.
You can also fold into non-numeric values:
let csv = ["red", "green", "blue"].iter().fold(
String::new(),
|mut acc, word| {
if !acc.is_empty() {
acc.push(',');
}
acc.push_str(word);
acc
},
);try_fold() Stops Early on Failure
Use try_fold() when each step may fail and you want to stop at the first error.
fn sum_csv_numbers(text: &str) -> Result<i32, std::num::ParseIntError> {
text.split(',').try_fold(0, |acc, piece| {
let n = piece.trim().parse::<i32>()?;
Ok(acc + n)
})
}Type flow:
split(',')yields&strparse::<i32>()yieldsResult<i32, ParseIntError>?returns early from thetry_foldclosure on errortry_fold(...)returnsResult<i32, ParseIntError>
This is a good example of iterator pipelines and ? working together.
fold() vs sum()
If you only want addition, prefer sum() because it says exactly what you mean.
let total: i32 = [1, 2, 3, 4].iter().copied().sum();If you want custom accumulation logic, use fold().
let weighted = [10, 20, 30]
.iter()
.enumerate()
.fold(0, |acc, (ix, n)| acc + (ix as i32 * *n));If you want custom accumulation that can fail, use try_fold().
A Useful Mental Model
Read iterator chains in two passes.
First ask:
What values would this pipeline produce if consumed?
Then ask:
What method finally consumes it?
That keeps the roles clear:
map,filter,rev,enumerate,take: shape the walksum,collect,fold,try_fold,count: run the walk
TL;DR
- iterators are lazy: adapters describe work, consumers perform it
- most iterators are single-pass, so consumed items are gone
split('+')returns an iterator, not a reusable listsum()is best for straightforward additioncollect()needs a destination type such asVec<_>fold()gives you full control over the accumulator and its typetry_fold()is the fallible version that can stop early with?
If you want the matching article about Option, Result, ?, and left-to-right type flow, see option-result-question-mark-blocks-closures.qmd.