What do managers and Rust iterators have in common?
They can both look very productive while doing absolutely nothing.
Rust iterators are lazy: they don't do any work until you ask for a result. That's great, because it means you can chain together many operations without creating intermediate collections.
You can filter, transform, and combine values in a single pipeline. And once you
get the hang of it, you'll find that iterators are often more readable than a
for loop. That's because you don't have to manage indexes or check bounds
yourself; it's all part of Rust's ergonomic iterator machinery, one of my
favorite parts of the language.
Here's how iterators work in practice:
.iter(), .into_iter(), .iter_mut(), or directly
from .chars(), .lines(), etc..map(...), .filter(...), .take(...). These are
lazy..collect(), .sum(), .count(),
.any(...), or a for loop.let words = vec!["hello", "café", "rust"];
let lengths: Vec<usize> = words
.iter()
.map(|word| word.chars().count())
.collect();
assert_eq!(lengths, vec![5, 4, 4]);
The three "iter" methods differ in what they yield:
.iter() yields &T (immutable references). Use when reading..iter_mut() yields &mut T. Use when modifying in place..into_iter() on an owned Vec<T> consumes the vector and yields T.
On borrowed collections, it yields references instead: &T for &Vec<T>
and &mut T for &mut Vec<T>.Some adapters change the item type. Here, iter yields &&str, but map
produces character counts of type usize. The compiler infers types through
the chain.
You can write the chain first, then add a type annotation on the binding if the
compiler needs one.
.collect() can produce many different collections. Tell it which one with a
type annotation: Vec<_>, HashMap<_, _>, String. The _ lets the compiler
fill in the inner types.
Remember the three little functions from the
word count chapter's exercise break? Each
one was a counter, a for loop, and a return. With iterators, the whole trio
shrinks to:
fn word_count(text: &str) -> usize { text.split_whitespace().count() }
fn char_count(text: &str) -> usize { text.chars().count() }
fn longest_word(text: &str) -> usize {
text.split_whitespace().map(|w| w.chars().count()).max().unwrap_or(0)
}
You no longer have to maintain the mut counters or keep track of the maximum
yourself.
A short warmup: calculate the total sales with an iterator consumer. The later exercises will combine consumers with adapters that change or select items.
You could add the values with a for loop and an accumulator. Here, sum asks
the iterator for each value and collapses the sequence into one total.
Useful from the Standard Library
<[T]>::iterproduces an iterator of shared references over the slice.Iterator::sumreduces a numeric iterator to a single total. The function'si32return type supplies its output type when you return the result directly. In other contexts, you may need an annotation (let total: i32 = ...) or the turbofish (.sum::<i32>()).
An array isn't an iterator yet. Once you have an iterator, which consumer produces a number rather than a collection? The function's return type can supply the numeric type.
/// Calculates total revenue from sales data.
///
/// The simplest iterator pattern: take a sequence, produce one number. You
/// could write a `for` loop with a running total, but the standard library can
/// collapse a numeric iterator down for you in one call. See:
/// <https://doc.rust-lang.org/std/iter/trait.Iterator.html>
fn calculate_total_revenue() -> i32 {
let sales = [1200, 850, 2300, 950, 1800, 3200, 1100, 2800];
sales.iter().sum()
}
#[test]
fn test_calculate_total_revenue() {
let total = calculate_total_revenue();
assert_eq!(total, 14200); // Sum of all sales
}
Normalize every email address to lowercase, preserving the input order. Unlike summing, this keeps one output for each input. Write an iterator pipeline that returns the new vector.
map is lazy: it just describes the transformation. Nothing runs until
collect (or another consumer) asks for the results.
Useful from the Standard Library
Vec::into_iterconsumes the vec and yields owned items. The input vector is no longer needed after this call.to_lowercaseonly borrows each string, so ownership is not required for the conversion itself.Iterator::mapapplies a closure to each item and produces a new iterator with the transformed items.Iterator::collectturns the pipeline back into a collection. The return type (Vec<String>) tellscollectwhich collection to produce.str::to_lowercasereturns a freshStringwith characters converted to lowercase. Lowercasing is not the same as Unicode case folding for caseless comparison.
Each input should produce exactly one output. With into_iter, your closure
receives an owned String. The transformation returns another String; you
don't need a mutable reference to the original.
/// Normalizes email addresses to lowercase.
///
/// Return one normalized address per input, in the same order, using an
/// iterator pipeline. See:
/// <https://doc.rust-lang.org/std/string/struct.String.html#method.to_lowercase>
fn normalize_emails(emails: Vec<String>) -> Vec<String> {
emails
.into_iter()
.map(|email| email.to_lowercase())
.collect()
}
#[test]
fn test_normalize_emails() {
let emails = vec!["Alice@EXAMPLE.COM".to_string(), "BOB@test.ORG".to_string()];
let normalized = normalize_emails(emails);
assert_eq!(normalized, vec!["alice@example.com", "bob@test.org"]);
}
With map you transformed every item, while filter keeps some items and drops
the rest. There is one borrowing detail to watch: usernames.into_iter() yields
&str, and filter gives its closure a reference to each item, so the closure
sees &&str.
Method calls such as s.starts_with(...) automatically dereference these
layers. The extra references can be hard to track at first. If the compiler
reports a missing &, check what the iterator yields and what the closure
receives. The iterators entry in the cheatsheet shows those
reference layers side by side.
Useful from the Standard Library
Iterator::filterkeeps only items where the predicate returnstrue. The closure receives a reference to the item, regardless of whether the iterator yields owned values or borrows.str::starts_withtakes achar(or another&str) and answers yes/no. Method-call syntax auto-derefs through the extra reference.collect()here picksVec<&str>straight from the return type. You don't need a turbofish here.
The predicate decides whether to keep an item; it doesn't return the item
itself. With an iterator of &str, that predicate receives &&str. String
method calls auto-deref through the extra reference.
/// Returns all users whose usernames start with 'a'.
///
/// Same idea, but instead of transforming each element you keep some and drop
/// others. Watch out for one borrowing gotcha: the closure receives a reference
/// to each element, not the element itself. See:
/// <https://doc.rust-lang.org/std/primitive.str.html#method.starts_with>
fn select_usernames_starting_with_a(usernames: Vec<&str>) -> Vec<&str> {
usernames
.into_iter()
.filter(|username| username.starts_with('a'))
.collect()
}
#[test]
fn test_select_usernames_starting_with_a() {
let users = vec!["alice", "admin", "bob", "anonymous", "charlie"];
let active = select_usernames_starting_with_a(users);
assert_eq!(active, vec!["alice", "admin", "anonymous"]);
}
This time the input is a &[&str], a borrowed slice of borrowed strings, so the
iterator yields &&str. You'll return owned Strings so the caller can keep
the results independently of the input. That lets us focus on the iterator chain
without adding lifetime annotations.
Combine what you've used so far to return only paths ending in .rs, in their
original order. An empty input or no matching paths should give an empty vector.
This time, choose how the adapters fit together yourself.
Useful from the Standard Library
Iterator::filterborrows each item for its predicate. When the iterator yields&&str, the predicate receives&&&str. String method calls auto-deref through these layers.Iterator::mapcan change the item type by applying a closure to each item.str::to_stringconverts a borrowed string slice into an ownedString. Auto-deref reaches through the extra reference for you.str::ends_withchecks whether a string ends with a given suffix.
There are two separate jobs: deciding which paths belong in the result and making the result independent of the input. Filtering alone doesn't change the item type. Where in your pipeline do borrowed strings become owned ones?
/// Finds all files with ".rs" extension.
///
/// Same idea as the previous one, but the input is a `&[&str]` (a borrowed
/// slice of borrowed strings), so the iterator yields `&&str`. We sidestep that
/// double-reference by returning owned `String`s; the lesson here is iterators,
/// not lifetimes. To go from `&&str` to `String`, reach for [`str::to_string`].
fn find_rust_files(files: &[&str]) -> Vec<String> {
files
.iter()
.filter(|file| file.ends_with(".rs"))
.map(|file| file.to_string())
.collect()
}
#[test]
fn test_find_rust_files() {
let files = &[
"main.rs",
"README.md",
"lib.rs",
"package.json",
"config.rs",
];
let rust_files = find_rust_files(files);
assert_eq!(rust_files, vec!["main.rs", "lib.rs", "config.rs"]);
}
In the ? chapter, you parsed each token
inside a for loop. Write sum_numbers again, this time with an iterator pipeline
and sum, without a loop or ?. Keep the same behavior: whitespace separates
integers, empty input returns Ok(0), and the first parse error ends the sum.
Assume the running total fits in an i32.
Iterator::sum
also works on an iterator of Result values. Its output can be a Result
containing either the total or the first error. The function's return type can
tell it which output type to use.
Compare the two versions after the tests pass. In the loop, ? returns from
sum_numbers when parsing fails. Here, sum stops asking for items and returns
an Err to its caller; it does not return from the surrounding function. Your
function can inspect that result or return it directly. Adding ? after sum
would propagate an error that sum has already found.
What type does parsing each token produce? Compare that with the items a
fallible sum can consume. If you unwrap each parse result first, what happens
to the error you were supposed to return?
/// Sums whitespace-separated integers with an iterator pipeline and fallible
/// `sum`.
fn sum_numbers(text: &str) -> Result<i32, std::num::ParseIntError> {
text.split_whitespace()
.map(|token| token.parse::<i32>())
.sum()
}
#[test]
fn test_sum_numbers() {
assert_eq!(sum_numbers("5\n10\n15").unwrap(), 30);
assert_eq!(sum_numbers(" 1 2 3 ").unwrap(), 6);
assert!(sum_numbers("5\nabc\n15").is_err()); // not a number
}
#[test]
fn test_sum_numbers_empty_and_signed() {
assert_eq!(sum_numbers(""), Ok(0));
assert_eq!(sum_numbers(" \t\n"), Ok(0));
assert_eq!(sum_numbers("-5 +2 3"), Ok(0));
}
#[test]
fn test_sum_numbers_first_error() {
let first_error = "abc".parse::<i32>().unwrap_err();
assert_eq!(sum_numbers("5 abc 99999999999999999999"), Err(first_error));
}
The tests below already contain the pipelines. Before running them, replace each
todo!() with your prediction; leave the pipelines unchanged.
For the first test, predict how many times the closure runs when nothing
consumes the iterator. For the second, predict the first result, the collected
batch, the next result, and the inputs visited by map. Write down the order of
the printed messages too. Then run the tests and compare the trace with your
predictions. Locally, use
cargo test --example 17_iterators _8_lazy_consumption:: -- --nocapture to see
output from passing tests.
next
asks for one item and returns an Option.by_ref
lets a consumer borrow an iterator so you can use it again afterward.take
limits how many items its consumer can request.Does asking for one filtered result visit just one input? After collecting the
batch, does the next call start over or resume? Try changing take(1) to
take(2), predict all the results again, and rerun.
map adapter doesn't call its closure. Something must ask for an
item.filter may request several inputs before it can yield one output.by_ref borrows the existing iterator; consuming that borrow advances the
original too.
#[test]
fn experiment_without_consumption() {
let mut calls = 0;
{
let _pipeline = (1..=8).map(|number| {
calls += 1;
number * 10
});
println!("pipeline constructed");
}
println!("closure calls: {calls}");
let expected_calls: usize = 0;
assert_eq!(calls, expected_calls);
}
#[test]
fn experiment_partial_consumption() {
let mut visited = Vec::new();
let (first, batch, next) = {
let mut pipeline = (1..=8)
.map(|number| {
visited.push(number);
println!("map visits {number}");
number * 10
})
.filter(|number| number % 20 == 0);
println!("pipeline constructed");
let first = pipeline.next();
println!("first: {first:?}");
let batch: Vec<_> = pipeline.by_ref().take(1).collect();
println!("batch: {batch:?}");
let next = pipeline.next();
println!("next: {next:?}");
(first, batch, next)
};
println!("visited: {visited:?}");
let expected_first: Option<i32> = Some(20);
let expected_batch: Vec<i32> = vec![40];
let expected_next: Option<i32> = Some(60);
let expected_visited: Vec<i32> = vec![1, 2, 3, 4, 5, 6];
assert_eq!(first, expected_first);
assert_eq!(batch, expected_batch);
assert_eq!(next, expected_next);
assert_eq!(visited, expected_visited);
}
You summed a numeric array with sum, transformed every element with map,
kept just the matching ones with filter, and combined filter with map to
convert borrowed slices into owned strings.
What We Learned
An iterator pipeline starts with
.iter(),.iter_mut(),.into_iter(), or a method such as.chars()or.lines(). Lazy adapters describe what should happen to each item. A consumer finishes the pipeline by asking for results.
iteryields&T, anditer_mutyields&mut T. On an ownedVec<T>,into_iterconsumes the vector and yieldsT. On borrowed collections, it yields references instead:&Tfor&Vec<T>and&mut Tfor&mut Vec<T>. Pick the one that matches what you intend to do with each item.Adapters (
map,filter,take,skip, ...) describe the pipeline but do nothing on their own. The actual work happens when a consumer (collect,sum,count,forloop) asks for results.
collectis generic over the target collection. The return type (or a turbofish like.collect::<Vec<_>>()) tells it what to build.
sumneeds to know its output type. The function's return type can supply it; otherwise annotate the binding or use.sum::<i32>().
filter's closure always takes&T, so on a&striterator you'll see&&str. Method calls auto-deref, so.starts_with(...)works through extra references; comparison operators sometimes need an explicit*.The
|x| ...syntax you've been seeing is a closure: an anonymous function passed as an argument.Fallible
sumstops at the firstErrand returns it as a value. Unlike?, it doesn't return from the surrounding function.
nextandtakecan consume only part of a pipeline. Withby_ref, you can resume the same iterator afterward; already-consumed items don't run again.
Extra practice to explore at your own pace. These chapters do not count toward course progress.