Trust is earned, not given

A different perspective

2023-03-07 · Projects

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.