Rust, part 3: iterators, closures, and what "zero-cost" really means
Part 3from the Rust series · 15 parts in all
"Zero-cost abstraction" is the phrase people quote and rarely test. For iterators it is
literally measurable: a chain of filter and map compiles to the same
machine code as the equivalent hand-written for loop, because the whole chain is
generic and the compiler monomorphizes it into straight-line code. Part 3 is how that works
and how closures fit in.
A chain is one loop
#[derive(Debug)]
struct Order { paid: bool, total_cents: u32, customer: String }
fn revenue(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| o.paid)
.map(|o| o.total_cents)
.sum()
}
There is no intermediate Vec here and no allocation: filter
returns a lazy adapter, map wraps that adapter, and sum drives the
whole thing once, consuming one element at a time. If you write the for-loop instead you get
identical code — the iterator version is not a stylistic preference with a performance
asterisk.
Iterators are also lazy, which is the one place they surprise people: an
adapter chain that nothing consumes does nothing at all. This is harmless, and rustc warns
with unused_must_use when a non-() value is discarded.
Closures capture by reference, by preference
let threshold = 5_000;
// Borrows `threshold` immutably; inferred by what the body does.
let big = orders.iter().filter(|o| o.total_cents > threshold).count();
// `move` takes ownership - required when the closure outlives this scope,
// which is exactly the case for a spawned thread:
let owned = orders.to_vec();
std::thread::spawn(move || {
println!("{} orders seen", owned.len());
});
A closure is a struct with a call method, and the compiler infers which of the
three traits it implements — Fn, FnMut or FnOnce —
from how it uses what it captured. That is why adding a println! can change
which trait bound satisfies, and why move is needed at a thread boundary: the
closure must own its data, because the scene it was written in is gone.
The cost that is not zero
Monomorphization is a real trade. A generic function used with seven types becomes seven
functions in the binary, so compile times and code size grow with use, and dynamic dispatch
(dyn Trait) exists for when that trade goes the wrong way. When someone says
Rust is "as fast as C" the accurate version is: it gives you the same control over
allocation and dispatch, and the defaults are the ones that do not allocate — which for
iterators means not allocating is the easy path, not the reward for knowing a trick. Next:
the year Rust stopped being a systems-language-only story and got merged into Linux.