“Zero-cost abstractions” is the slogan people repeat about Rust more than any other, and it is repeated with a confidence that is usually not earned. This article is about earning it: what the phrase actually claims, the four places Rust does not deliver on it, and why — after all four caveats — the abstractions are still remarkable.
The goal is a calibrated understanding rather than a devotional one. That is, honestly, the most “expert edge” thing in this whole course. Anyone can learn Iterator. Knowing when it costs you something is what makes you useful in a code review.
What the phrase means, precisely
It comes from Bjarne Stroustrup, describing C++:
What you don’t use, you don’t pay for. And further: what you do use, you couldn’t hand code any better.
Two claims, and both are relative. The first is about unused features — a language where merely having an abstraction available slows down programs that ignore it has failed. The second is about used features — the abstraction should compile to what a competent programmer would have written by hand.
Neither claim says “free”. “Zero-cost” has always meant zero runtime cost relative to the hand-written equivalent, and it has never meant zero compile time, zero binary size, zero cognitive load, or zero cost in every dimension anyone might care about. Half the arguments about this phrase are people talking past each other on exactly that point.
💡Before reading on: which of these do you expect to compile to identical machine code, and which not? click to reveal
// (a)
let mut total = 0u64;
for i in 0..v.len() { total += v[i]; }
// (b)
let total: u64 = v.iter().sum();
// (c)
fn area(s: &dyn Shape) -> f64 { s.area() }
fn area<S: Shape>(s: &S) -> f64 { s.area() }
(a) and (b) genuinely do compile to the same thing in almost every case — the iterator version often better, because it does not have to prove the index is in bounds on each step and (a) does.
(c) is the interesting one. The generic version is monomorphised and inlinable. The dyn version is an indirect call through a vtable — but if the optimiser can see which concrete type reaches that call site, it devirtualises and the difference vanishes. Across a crate boundary, with a value that genuinely varies at runtime, it does not. “Are they the same?” has no answer without “at which call site?”
Hold on to that shape. Most of this article is variations of it.
The four honest exceptions
1. Iterator adaptors with degraded size_hint
size_hint is how an iterator tells collect how much to allocate up front. Adaptors that cannot know their output length — filter, flat_map, take_while, skip_while — return a hint of (0, None) or a lower bound of zero. Vec then grows by reallocating and copying, repeatedly, where a hand-written loop with Vec::with_capacity would have allocated once.
The measured case from this course: collecting a flat_map over nested vectors ran 6.5× slower than concat(), which knows the total length and allocates exactly once. That is not a rounding error. It is the difference between an abstraction that costs nothing and one that costs a factor of six.
The fix is not “stop using iterators”. It is to know that collect is the point where a bad hint becomes a bill, and to reach for with_capacity plus extend when you know the size and the adaptor chain does not.
2. Bounds checks are elided sometimes, and the compiler will not tell you which
Every v[i] on a slice is a comparison and a branch. LLVM removes it whenever it can prove i < v.len() — which it usually can for for i in 0..v.len(), for iterator-driven loops, and for chunks_exact, whose whole purpose is to make the length provable.
It cannot always. Two slices indexed by the same variable, an index computed through arithmetic it cannot bound, a loop whose trip count depends on a function call — any of these can leave the check in. Measured here: adding an explicit assert!(a.len() == b.len()) before a dot-product loop made it 1.48× faster, purely because that one assertion let LLVM drop the per-iteration checks from both slices.
The uncomfortable part is not the cost. It is that there is no way to ask. rustc will not tell you which bounds checks survived; you read the assembly, or you measure. An abstraction whose cost you cannot predict without measuring is not, in the ordinary sense of the word, zero-cost — even when it usually is.
3. async traded zero-cost purity for usability
The generated state machine really is cheap: one allocation per task rather than one stack per thread, and a poll that is a jump table. That part delivers.
The rest of the story is a tax that does not show up in a microbenchmark. Pin exists because a self-referential state machine cannot be moved, and it makes writing a combinator by hand genuinely hard — you saw exactly this when every combinator in the executor capstone needed an Unpin bound to stay readable. Async functions in traits took years and still have sharp edges. Every future must be Send to be spawned on a work-stealing runtime, and the diagnostic when one is not is famously bad. And a boxed dyn Future — which you need the moment you want to store futures of different shapes — is an allocation and an indirect call per poll.
None of that is runtime cost in the Stroustrup sense. All of it is cost. The design chose usability over purity at several points, and being able to say so is more useful than pretending the choice did not happen.
4. Monomorphisation moves cost rather than removing it
A generic function is compiled once per concrete type it is used with. That is exactly why the generic version has no indirect call and inlines well. It is also why the compiler is doing N times the work, and why the binary contains N copies.
Measured here: swapping a generic parameter for Box<dyn Trait> in a small program changed the binary size by 2.7% — small, but the direction is real, and it grows with the number of instantiations. In large codebases with deeply generic APIs, monomorphisation is one of the main reasons Rust compiles slowly, and slow compilation is a cost paid by every developer on every edit, all day.
So it is not that the cost vanished. It moved from the runtime bill to the compile-time and binary-size bill. That is usually a good trade. It is still a trade.
Now the other side, which matters more
Having said all that: the measurements in this course mostly vindicate the claim, and it would be dishonest to end on the caveats.
-
filter/map/collectchains benchmark at parity with hand-written index loops on the same data. Not close to parity — parity. The iterator was compiled away entirely. -
dyn Traitdispatch, at call sites where the concrete type is visible, benchmarks at parity with generics, because LLVM devirtualises it. -
The
Iteratortrait is thirty-odd default methods built on one required method, and none of them costs a function call in optimised code. -
Option<&T>is the same size as&T.Result<(), E>whereEis a fieldless enum is a byte. Newtypes are free.#[repr(transparent)]is free.Derefis free. - Ownership itself is the largest zero-cost abstraction in the language: the entire memory-safety story compiles to nothing at runtime. No GC, no refcount, no header word, no write barrier. That is not a small thing, and it is easy to stop noticing.
Four exceptions in a language whose central claim is this ambitious is a good score. The exceptions are all specific and learnable, which is the useful property: you can be told about degraded size_hint once and then avoid it forever.
Where the argument actually is
There is a real disagreement in the community, and it is worth knowing both sides rather than picking one.
The enthusiastic tutorial genre treats “zero-cost” as a settled property of the language and rarely qualifies it. That is not dishonest so much as pedagogical — a beginner who worries about size_hint before they can write a for loop is being badly served.
The critical essays — Boats writing on async and on Pin, the polybdenum posts on the limits of the abstractions — argue that the phrase has done real damage by making costs unsayable. Their strongest point is not that the abstractions are expensive. It is that a slogan which admits no exceptions produces engineers who cannot see the exceptions, and who therefore cannot debug them.
Both are right about different audiences. The synthesis, and the thing worth carrying: treat “zero-cost” as a strong prior, not an axiom. It will be correct most of the time. When your profiler disagrees with it, the profiler is right, and you now have four specific places to look first.
What not to say
Do not compare with C++ unless you have measured that specific comparison. “Rust’s iterators are faster than C++’s” and “Rust’s monomorphisation is worse than C++ templates” are both claims you will see confidently asserted by people who have benchmarked neither. The two languages share an optimiser, share the monomorphisation strategy, and differ in the aliasing information they hand LLVM. Which wins on a given workload is an empirical question with a workload-specific answer.
And do not turn calibration into cynicism. The correct posture after reading this is not “so it’s all marketing”. It is: the abstractions are excellent, they are free far more often than not, and here are the four situations where I should check.