The phrase “zero-cost abstractions” appears in every Rust introduction, usually alongside a claim that you get high-level expressiveness for free. After two years of writing geospatial tools in Rust — culminating in the Spatial Narrative library — I can say the claim holds, with some nuance worth understanding.
This post looks at what zero-cost abstractions actually mean in the context of coordinate processing, and at two concrete Rust mechanisms that deliver on the promise: iterators and traits.
What “Zero-Cost” Actually Means
The phrase comes from Bjarne Stroustrup’s original formulation about C++: “What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.”
In Rust this is enforced more rigorously than in C++. Rust iterators, for example, are defined in terms of traits, and the compiler monomorphises and inlines them aggressively. The result is that a chain of .map(), .filter(), and .fold() over a coordinate array compiles to the same machine code as the equivalent explicit loop — sometimes better, because the compiler can vectorise it more readily.
Let’s make this concrete.
Iterator Chains Over Coordinates
Suppose you have a dataset of a million (lat, lng) pairs stored as a Vec<[f64; 2]>. You want to:
- Filter to points within a bounding box
- Convert each to a Mercator projection
- Compute the centroid of the surviving points
use std::f64::consts::PI;
#[derive(Debug, Clone, Copy)]
struct LatLng {
lat: f64,
lng: f64,
}
#[derive(Debug, Clone, Copy)]
struct Mercator {
x: f64,
y: f64,
}
fn to_mercator(p: LatLng) -> Mercator {
let x = p.lng.to_radians() * 6_378_137.0;
let lat_r = p.lat.to_radians();
let y = ((PI / 4.0) + (lat_r / 2.0)).tan().ln() * 6_378_137.0;
Mercator { x, y }
}
struct BoundingBox {
min_lat: f64, max_lat: f64,
min_lng: f64, max_lng: f64,
}
impl BoundingBox {
fn contains(&self, p: &LatLng) -> bool {
p.lat >= self.min_lat && p.lat <= self.max_lat &&
p.lng >= self.min_lng && p.lng <= self.max_lng
}
}
fn centroid_in_bbox(points: &[LatLng], bbox: &BoundingBox) -> Option<Mercator> {
let (sum_x, sum_y, count) = points
.iter()
.filter(|p| bbox.contains(p))
.map(|&p| to_mercator(p))
.fold((0.0_f64, 0.0_f64, 0_usize), |(sx, sy, n), m| {
(sx + m.x, sy + m.y, n + 1)
});
if count == 0 {
None
} else {
Some(Mercator {
x: sum_x / count as f64,
y: sum_y / count as f64,
})
}
}
This reads cleanly. The iterator chain expresses intent directly: filter, project, accumulate. And crucially, rustc will compile this to a tight loop — no heap allocation for intermediate results, no virtual dispatch, no boxing. The same logic written as an explicit for loop produces bytecode that is, in practice, identical.
You can verify this yourself with Compiler Explorer (Godbolt): paste the iterator version and the loop version, compile with -O, and compare the assembly output.
Custom Traits for Spatial Types
Rust traits let you define shared behaviour without inheritance. This is where the second dimension of zero-cost abstractions comes in: you can write generic algorithms that work over any type implementing a trait, and the compiler generates specialised code for each concrete type at compile time (monomorphisation). There is no runtime dispatch overhead.
Here is a minimal Spatial trait:
pub trait Spatial {
fn lat(&self) -> f64;
fn lng(&self) -> f64;
fn haversine_distance(&self, other: &impl Spatial) -> f64 {
const R: f64 = 6_371_000.0; // Earth radius in metres
let phi1 = self.lat().to_radians();
let phi2 = other.lat().to_radians();
let delta_phi = (other.lat() - self.lat()).to_radians();
let delta_lambda = (other.lng() - self.lng()).to_radians();
let a = (delta_phi / 2.0).sin().powi(2)
+ phi1.cos() * phi2.cos() * (delta_lambda / 2.0).sin().powi(2);
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
R * c
}
}
Any type that exposes lat() and lng() gets haversine_distance for free via the default method. Now implement it for a lightweight newtype:
#[derive(Debug, Clone, Copy)]
pub struct Point {
pub lat: f64,
pub lng: f64,
}
impl Spatial for Point {
fn lat(&self) -> f64 { self.lat }
fn lng(&self) -> f64 { self.lng }
}
// And now for a richer event type with metadata
pub struct Event {
pub location: Point,
pub timestamp: i64,
pub label: String,
}
impl Spatial for Event {
fn lat(&self) -> f64 { self.location.lat }
fn lng(&self) -> f64 { self.location.lng }
}
Because haversine_distance takes impl Spatial, the compiler generates a separate, specialised version for Point vs Event. No virtual table, no indirection — the function call is inlined where the types are known at compile time.
Where Abstractions Actually Have a Cost: dyn Trait
Zero-cost applies to static dispatch (generics and impl Trait). Dynamic dispatch (dyn Trait) does have an overhead: the function call goes through a vtable, similar to virtual functions in C++. This matters in tight loops.
// Static dispatch — zero cost
fn nearest<T: Spatial>(query: &T, candidates: &[T]) -> Option<&T> { ... }
// Dynamic dispatch — vtable overhead per call
fn nearest_dyn(query: &dyn Spatial, candidates: &[dyn Spatial]) -> Option<&dyn Spatial> { ... }
For geospatial work, static dispatch is almost always the right choice when processing coordinate arrays. The times to reach for dyn Trait are when you genuinely need a heterogeneous collection of types at runtime — for example a vector of mixed geometry types like Point, LineString, and Polygon — where the flexibility is worth the cost.
Benchmarking the Difference
Using criterion to benchmark haversine distance computed over 1,000,000 point pairs:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench_haversine(c: &mut Criterion) {
let points: Vec<Point> = generate_random_points(1_000_000);
let origin = Point { lat: 52.9, lng: -1.15 }; // Nottingham
c.bench_function("haversine_static", |b| {
b.iter(|| {
points.iter()
.map(|p| black_box(origin.haversine_distance(p)))
.sum::<f64>()
})
});
}
On an AMD Ryzen 5 7600X, the static dispatch path processes approximately 420 million distance calculations per second, well within the range you would expect from a tight SIMD-vectorised loop. A Python equivalent using NumPy’s haversine implementation (which itself calls optimised C) is typically 8–15× slower at this scale due to interpreter overhead and memory layout differences.
Practical Implications for Spatial Libraries
The Spatial Narrative library relies heavily on these patterns. The R*-tree index uses a generic RTreeEntry<T: Spatial> so that the same index structure works for plain Point, Event, and any future type without duplicating code. The compiler generates specialised tree nodes and search routines for each type used in practice.
This means you get:
- No allocation overhead for intermediate spatial results
- LLVM-optimised inner loops for distance comparisons during index traversal
- Type safety — you cannot accidentally query a point index with an event that is not
Spatial - Readable, composable code that performs like hand-written C
That last point is the practical payoff of zero-cost abstractions: you do not have to choose between readability and performance. In most systems languages, expressive code and fast code are in tension. In Rust, the compiler removes that tension.
Further Reading
- The Rust Performance Book — comprehensive guidance on profiling and optimising Rust code
- Rustonomicon: Generics and Monomorphisation — the low-level detail of how traits are compiled
- Spatial Narrative crate — the library these patterns came from