Zero-Cost Abstractions
Zero-cost abstractions are high-level programming constructs—such as generics, traits, closures, iterators, Option<T>, and newtype wrappers—that compile down to machine code at least as fast and compact as hand-written low-level code. Coined by Bjarne Stroustrup for C++, the principle has two parts: what you do not use, you do not pay for; and what you do use, you could not hand-code any better.
How Rust Achieves Zero Runtime Overhead
In many high-level languages, abstractions carry runtime costs: generic types box values on the heap, closures allocate environment objects, and iterator chains create intermediate arrays at each step. Rust eliminates these costs during compilation through four mechanisms:
| Mechanism | High-Level Abstraction | What rustc and LLVM Generate |
|---|---|---|
| Monomorphization | Generic functions (fn max<T: Ord>(a: T, b: T)) and structs |
Dedicated, type-specific machine functions with direct static calls and no boxing or vtable lookup |
| Inlining and Iterator Fusion | Iterator chains (.iter().filter(...).map(...).sum()) |
A single tight CPU loop with closure bodies inlined directly into the loop body |
| Newtype and Wrapper Erasure | Single-field structs (struct Meters(f64);) and references |
Identical register/stack layout to the inner primitive (f64); wrapper exists only during type checking |
| Niche Optimization | Option<&T>, Option<Box<T>>, Option<NonZeroU32> |
Uses the invalid 0x0 null bit pattern for None, keeping size_of::<Option<&T>>() == size_of::<&T>() |
Monomorphization of Generics
When you write a generic function or struct, rustc performs monomorphization: it stamps out a specialized copy of the function for each concrete type actually used in the program, and then optimizes each copy independently.
Generic Source Code:
fn add_pair<T: std::ops::Add<Output = T>>(a: T, b: T) -> T { a + b }
let x = add_pair(3_i32, 4_i32);
let y = add_pair(1.5_f64, 2.5_f64);
|
v (Monomorphization during compilation)
Specialized Machine Functions:
fn add_pair_i32(a: i32, b: i32) -> i32 { a + b } --> CPU `add w0, w0, w1`
fn add_pair_f64(a: f64, b: f64) -> f64 { a + b } --> CPU `fadd d0, d0, d1`
fn max_value<T: Ord>(a: T, b: T) -> T {
if a >= b { a } else { b }
}
fn main() {
let int_max = max_value(10_i32, 25_i32);
let str_max = max_value("apple", "pear");
println!("int_max = {int_max}, str_max = {str_max}");
}
int_max = 25, str_max = pear
If a generic type or method is never instantiated in your program, rustc generates zero machine instructions for it.
Iterator Chains vs Hand-Written Loops
Consider summing the squares of all even numbers in a slice. You can write this either as an imperative index loop or as a declarative chain of iterator adapters (filter, map, sum):
fn sum_even_squares_loop(values: &[u64]) -> u64 {
let mut total = 0;
for i in 0..values.len() {
let v = values[i];
if v % 2 == 0 {
total += v * v;
}
}
total
}
fn sum_even_squares_iter(values: &[u64]) -> u64 {
values
.iter()
.copied()
.filter(|&v| v % 2 == 0)
.map(|v| v * v)
.sum()
}
fn main() {
let data = [1, 2, 3, 4, 5, 6];
assert_eq!(sum_even_squares_loop(&data), sum_even_squares_iter(&data));
println!("Sum of even squares: {}", sum_even_squares_iter(&data));
}
Sum of even squares: 56
In sum_even_squares_iter:
.iter(),.copied(),.filter(...), and.map(...)are lazy—none of them allocate a temporaryVecor traverse the slice when called. Each adapter returns a small stack struct wrapping the previous iterator.- When
.sum()drives the iterator in an optimized build (-O), the compiler inlines thenext()calls and closures, fusing the entire chain into a single loop that is identical to (or faster than, due to elided bounds checks and SIMD auto-vectorization)sum_even_squares_loop.
Zero-Cost Type Safety: The Newtype Pattern
Wrapping a primitive type in a single-field tuple struct (a newtype) lets the compiler prevent unit-mixing bugs (such as adding meters to seconds) with zero memory or CPU cost at runtime:
use std::mem::size_of;
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Seconds(f64);
fn speed(distance: Meters, time: Seconds) -> f64 {
distance.0 / time.0
}
fn main() {
let d = Meters(100.0);
let t = Seconds(9.58);
println!("Speed: {:.2} m/s", speed(d, t));
println!("size_of::<f64>() = {} bytes", size_of::<f64>());
println!("size_of::<Meters>() = {} bytes", size_of::<Meters>());
}
Speed: 10.44 m/s
size_of::<f64>() = 8 bytes
size_of::<Meters>() = 8 bytes
Passing speed(Seconds(9.58), Meters(100.0)) is rejected at compile time (E0308: expected Meters, found Seconds), yet at runtime Meters(100.0) is passed in the exact same floating-point CPU register as a bare 100.0_f64.
Zero-Cost Null Safety with Niche Optimization
In C, a pointer const int* occupies 8 bytes (on 64-bit systems) and uses address 0x0 (NULL) to represent absence. In Rust, references (&T) and smart pointers (Box<T>, NonNull<T>) are guaranteed never to be null (0x0).
When you wrap a reference in Option<&T>, the compiler uses 0x0 to represent None and any non-zero address to represent Some(&T). As a result, Option<&T> has the exact same 8-byte size and machine representation as a nullable C pointer, while forcing you to check for None at compile time:
use std::mem::size_of;
fn main() {
println!("size_of::<&i32>() = {} bytes", size_of::<&i32>());
println!("size_of::<Option<&i32>>() = {} bytes", size_of::<Option<&i32>>());
println!("size_of::<Box<i32>>() = {} bytes", size_of::<Box<i32>>());
println!("size_of::<Option<Box<i32>>>() = {} bytes", size_of::<Option<Box<i32>>>());
}
size_of::<&i32>() = 8 bytes
size_of::<Option<&i32>>() = 8 bytes
size_of::<Box<i32>>() = 8 bytes
size_of::<Option<Box<i32>>>() = 8 bytes
Trade-Offs of Zero-Cost Abstractions
While zero-cost abstractions eliminate runtime execution overhead, they shift work to the compiler:
| Dimension | Benefit | Trade-Off |
|---|---|---|
| Runtime speed | Direct calls, aggressive inlining, SIMD vectorization | None |
| Compilation time | N/A | Heavy generic instantiation and optimization increase compile times |
| Binary size | Unused generic functions are stripped | Monomorphizing large generic functions across many types can increase code size ("code bloat") compared to dynamic dispatch (dyn Trait) |