Rust, part 5: traits, generics and monomorphization — static versus dynamic dispatch
Part 5from the Rust series · 15 parts in all
Rust's generics look like C++ templates and its traits look like Haskell type classes, but the question that decides your program's shape is the one C++ and Haskell answer differently too: is this call resolved at compile time or at runtime? Rust makes you choose explicitly, and both choices are spelled clearly enough to see in a signature.
The same function, two ways
trait Storage {
fn put(&mut self, key: &str, value: &[u8]) -> std::io::Result<()>;
}
// Static dispatch: one copy of this function per S, resolved at compile time.
fn save_static<S: Storage>(store: &mut S) -> std::io::Result<()> {
store.put("k", b"v")
}
// Dynamic dispatch: one copy, the concrete type behind a vtable pointer.
fn save_dyn(store: &mut dyn Storage) -> std::io::Result<()> {
store.put("k", b"v")
}
The first is monomorphized: the call to put is resolved and
inlined, the optimizer can see through it, and it is as fast as writing the code by hand. Its
costs are compilation time and binary size, since three storage types mean three
save_static functions.
The second goes through a vtable — one more indirection per call, opaque to the optimizer,
and perfectly fine for anything that is not in a hot loop. It also buys the thing generics
cannot give you: a Vec<Box<dyn Storage>> holding different
types.
The ergonomic middle: impl Trait
// Argument position: sugar for a generic parameter.
fn open(path: &str, store: impl Storage) { /* ... */ }
// Return position: "some type that implements Iterator, name not important".
fn evens(v: Vec<i32>) -> impl Iterator<Item = i32> {
v.into_iter().filter(|n| n % 2 == 0)
}
Both are static dispatch. The return-position form is the one that makes iterator chains
usable as API: before it, a function returning a filtered iterator had to name a type nobody
can write down, so it had to collect into a Vec and allocate.
Rust also refuses to let you have the worst of both: a function with a dyn
return must say who owns the trait object (Box<dyn Trait>), and a
dyn trait must be object safe — no generic methods, no
Self return types. Those restrictions are not arbitrary; they are exactly the
parts that cannot fit through a vtable. Next: the feature that scares people before they
write any Rust — lifetimes.