Wesley.dev

Rust's Zero-Cost Abstractions: What They Are and Aren't

Iterators, closures, and trait objects — which abstractions compile away and which ones don't, with benchmarks.

Wesley Sum · · 2 min read

“Zero-cost abstraction” is Rust’s most famous marketing claim. It’s mostly true, but the nuance matters when you’re chasing nanoseconds.

The claim

What you don’t use, you don’t pay for. What you do use, you couldn’t hand-code any better. — Bjarne Stroustrup (adapted for Rust)

Iterators: the canonical example

Rust iterators are lazy and monomorphised at compile time.

fn sum_squares(v: &[f64]) -> f64 {
v.iter()
.map(|x| x * x)
.filter(|&x| x > 1.0)
.sum()
}

Compile with --release and look at the assembly:

Terminal window
cargo rustc --release -- --emit asm
grep -A 20 'sum_squares' target/release/deps/*.s

You’ll see tight SIMD loops — the iterator chain evaporates. The equivalent hand-written C would look identical.

Warning

Debug builds do not apply these optimisations. Always benchmark with --release.

Closures: three flavours

Rust has three closure traits:

TraitCan callMemory
FnMultiple timesImmutable borrow
FnMutMultiple timesMutable borrow
FnOnceOnceConsumes captured vars

When a closure doesn’t escape its call site, it’s inlined and fully optimised away.

// This allocates nothing — closure is stack-only
let multiplier = 3;
let result: Vec<i32> = (0..10).map(|x| x * multiplier).collect();

Where you do pay: trait objects

dyn Trait is a fat pointer (data + vtable) with a runtime dispatch cost:

// Static dispatch — zero cost
fn process_static<T: Processor>(p: &T) { p.run(); }
// Dynamic dispatch — vtable lookup
fn process_dynamic(p: &dyn Processor) { p.run(); }

The vtable indirection prevents inlining, which can inhibit LLVM’s subsequent optimisations. For tight loops, prefer generics.

Benchmarking the difference

use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_static(c: &mut Criterion) {
let data = vec![1.0f64; 10_000];
c.bench_function("static dispatch", |b| {
b.iter(|| sum_squares(black_box(&data)))
});
}
criterion_group!(benches, bench_static);
criterion_main!(benches);

On my M3 MacBook Pro, static dispatch on a 10k float slice runs at ~5µs; switching to dyn Fn adds ~40% overhead on that tight loop.

Tip

Use dyn Trait at API boundaries where flexibility matters. Avoid it in hot loops.

Takeaway

Rust’s zero-cost abstractions are real for iterators, generics, and closures. They’re not free for dynamic dispatch. The rule of thumb: if the type is known at compile time, the abstraction is free. If it isn’t, you’re paying for the flexibility.