impl Trait gives you static dispatch with cleaner syntax, both as an argument and as a return type;Learn Rust Series):Last episode we met trait objects and dynamic dispatch, and I promised at the very end that we would put them head to head against generics and measure the difference in our heads, rather than taking my word for which is faster. That is exactly what today is about. Static dispatch and dynamic dispatch solve the same problem -- "call a method on some type that implements a trait" -- but they make opposite trade-offs to get there, and knowing which one to reach for is one of those quiet skills that separates code that is merely correct from code that is correct and appropriately fast. Having said that, before we open the topic, we clear last episode's homework, as always ;-)
Episode 15 was trait objects: dyn Trait, fat pointers, vtables and object safety. There were three exercises, and here is how each lands with full code you can paste and run.
Exercise 1 asked you to add a Triangle { base: f64, height: f64 } type, implement Shape for it, and push one into the Vec<Box<dyn Shape>> alongside the circle and the square -- noticing that you change exactly one line at the call site and not a single character inside the loop:
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
struct Triangle { base: f64, height: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
fn name(&self) -> &str { "circle" }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
fn name(&self) -> &str { "square" }
}
impl Shape for Triangle {
fn area(&self) -> f64 { 0.5 * self.base * self.height }
fn name(&self) -> &str { "triangle" }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Square { side: 2.0 }),
Box::new(Triangle { base: 3.0, height: 4.0 }), // the one new line
];
for shape in &shapes {
println!("{}: area {:.3}", shape.name(), shape.area());
}
}
The whole point of the exercise is that last Box::new(Triangle { .. }) is the only edit. The render loop does not know a triangle now exists, does not care, and does not change. That is the extensibility win of trait objects in one line: the code that uses the shapes is frozen, while the set of shape types keeps growing. The triangle's area is 0.5 * base * height, and name returns "triangle" exactly as the other two return their own labels.
Exercise 2 wanted you to extend the Draw toolkit with a Slider { value: u8 } widget whose draw renders something like <value>/255, and then render a mixed Vec<Box<dyn Draw>> containing a button, a checkbox and your slider:
trait Draw {
fn draw(&self) -> String;
}
struct Button { label: String }
struct Checkbox { checked: bool }
struct Slider { value: u8 }
impl Draw for Button {
fn draw(&self) -> String { format!("[ {} ]", self.label) }
}
impl Draw for Checkbox {
fn draw(&self) -> String {
if self.checked { String::from("[x]") } else { String::from("[ ]") }
}
}
impl Draw for Slider {
fn draw(&self) -> String { format!("", self.value) }
}
fn main() {
let ui: Vec<Box<dyn Draw>> = vec![
Box::new(Button { label: String::from("Save") }),
Box::new(Checkbox { checked: true }),
Box::new(Slider { value: 200 }),
];
for widget in &ui {
println!("{}", widget.draw());
}
}
Same lesson, viewed from the Draw side. Slider is a brand new concrete type, its draw just formats its value into the <200/255> shape, and it slots into the heterogeneous Vec<Box<dyn Draw>> next to the button and the checkbox without the loop caring in the slightest. Three different types, one loop, one uniform draw() call resolved per widget at runtime -- textbook dynamic dispatch.
Exercise 3 was the reasoning one: write two functions with the same job, fn render_dyn(item: &dyn Draw) and fn render_generic<T: Draw>(item: &T), and then work out on paper which one could go into a Vec of mixed widget types and why the other one cannot:
trait Draw { fn draw(&self) -> String; }
struct Button { label: String }
impl Draw for Button {
fn draw(&self) -> String { format!("[ {} ]", self.label) }
}
fn render_dyn(item: &dyn Draw) {
println!("{}", item.draw()); // one function, dynamic dispatch
}
fn render_generic<T: Draw>(item: &T) {
println!("{}", item.draw()); // monomorphized, static dispatch
}
fn main() {
let ok = Button { label: String::from("OK") };
render_dyn(&ok);
render_generic(&ok);
}
Both compile and both print the same thing for a single button, but they are not the same function. render_generic is monomorphized: the compiler stamps out a fresh specialised copy for every concrete T you ever call it with, and each copy dispatches statically and can inline the draw call. render_dyn compiles to exactly one function in the whole binary, and it dispatches through the vtable every time. The reason &dyn Draw is the one that can live in a mixed Vec<Box<dyn Draw>> is precisely that it has a single, uniform type regardless of what it points at, whereas <T: Draw> bakes in one concrete type per instantiation. Right -- homework cleared, on to the trade-off itself ;-)
When you call a generic function, the compiler does not produce one clever function that somehow works for all types at once. It stamps out a separate, specialised copy for each concrete type you actually use, with the type filled in and the exact method calls baked in. That process is called monomorphization (turning one generic "poly" thing into many "mono" concrete ones), and it is what "static dispatch" fundamentally means: the target of every call is fixed at compile time, before your program ever runs:
trait Greet { fn hello(&self) -> String; }
struct English;
struct French;
impl Greet for English { fn hello(&self) -> String { String::from("hello") } }
impl Greet for French { fn hello(&self) -> String { String::from("bonjour") } }
fn greet_static<T: Greet>(g: &T) {
println!("{}", g.hello());
}
fn main() {
greet_static(&English); // compiler generates greet_static::
greet_static(&French); // and a separate greet_static::
}
At compile time this single greet_static becomes two real functions in the binary: one whose body calls English::hello directly, and one whose body calls French::hello directly. There is no lookup at runtime, no indirection, nothing to decide -- each call already knows exactly which function it lands in. And because the concrete type is known, the compiler can inline the call, meaning it can paste the body of hello straight into the call site and then optimise across the seam. That is why static dispatch is, in the good case, as fast as hand-written non-generic code: after monomorphization there is nothing generic left, just ordinary specialised functions.
The cost lives elsewhere, and it is real even if it is usually small: two concrete types means two copies of the function, ten concrete types means ten copies. That is the "code bloat" people mention when a heavily generic library balloons in binary size and compile time -- the compiler is dutifully generating a specialised copy of everything for every type it is used with. For most programs you never notice. For a big generic-heavy dependency instantiated across dozens of types, it can add up.
The trait-object version looks almost identical on the page but behaves oppositely underneath. There is a single greet_dyn function in the binary, and it decides which hello to call at runtime by following the vtable of whatever trait object it was handed (remember from last episode: a &dyn Trait is a fat pointer carrying a data pointer plus a pointer to a table of the trait's method addresses):
trait Greet { fn hello(&self) -> String; }
struct English;
struct French;
impl Greet for English { fn hello(&self) -> String { String::from("hello") } }
impl Greet for French { fn hello(&self) -> String { String::from("bonjour") } }
fn greet_dyn(g: &dyn Greet) {
println!("{}", g.hello()); // vtable lookup, decided at runtime
}
fn main() {
greet_dyn(&English);
greet_dyn(&French);
}
One function in the binary, no matter how many types you throw at it, so no bloat. But every call pays for one extra indirection -- jump through the vtable to find the real hello, then call it -- and, usually the bigger deal, the compiler cannot inline a call it will only resolve at runtime, so it also forfeits the cascade of optimisations that inlining unlocks. In a tight inner loop that runs millions of times, that lost inlining can matter. Called occassionally, in code that is not on a hot path, it is completely invisible -- the indirection is a single pointer hop, cheaper than most things your program does per iteration anyway.
And here is the part worth holding onto: dynamic dispatch is also what makes the heterogeneous Vec<Box<dyn Shape>> from last episode possible in the first place. That flexibility -- mixing many concrete types behind one interface -- is very often the real reason you reach for a trait object, not the code-size saving. You are usually not choosing dyn to shave bytes off your binary; you are choosing it because you genuinely need a list of different things that share a trait.
Put side by side, the whole decision fits in a small table you can keep in your head:
| Static (generics) | Dynamic (dyn Trait) | |
|---|---|---|
| Dispatch decided | Compile time | Runtime (vtable) |
| Speed | Fast, inlinable | One indirection per call, no inlining |
| Code size | One copy per concrete type | One function total |
Mixed types in one Vec | No | Yes |
| Type info kept | Full concrete type | Erased behind the trait |
Neither column is "better" -- they sit at opposite ends of a speed-versus-flexibility line. And I want to stress something that trips up a lot of newcomers who have just learned the word "vtable" and start fearing it: for the overwhelming majority of code the difference is imperceptible. A vtable call is a pointer indirection, not a network round trip. So the right default posture is to choose for clarity first, and only reach for the static-dispatch micro-optimisation when a profiler actually points at a dispatch that is hot. Choosing dyn versus generics based on a vibe about performance, with no measurement, is how you end up with uglier code and no faster program.
There is a middle ground in ergonomics (not in mechanics) called impl Trait. As an argument type, &impl Greet is exactly sugar for a generic parameter -- same monomorphization, same static dispatch -- just shorter and more readable to write when you do not need to name the type parameter anywhere:
trait Greet { fn hello(&self) -> String; }
struct English;
impl Greet for English { fn hello(&self) -> String { String::from("hello") } }
fn greet(g: &impl Greet) { // identical to fn greet(g: &T)
println!("{}", g.hello());
}
fn main() {
greet(&English);
}
Read &impl Greet as "a reference to some type that implements Greet, and I do not care to give that type a name". It compiles to precisely the same thing as fn greet<T: Greet>(g: &T) would -- static dispatch, monomorphized per concrete type. It is purely a way to make generic signatures read more naturally when the type parameter would only have appeared once anyway. Nota bene: this is argument position; return position is a different and more interesting story, which is next.
As a return type, impl Trait earns its keep in a way a plain generic parameter cannot. It lets a function hand back an unnameable type -- a specific closure, or a specific iterator pipeline -- without boxing it and without you having to spell out the monstrous concrete type by hand. This is the idiomatic, zero-overhead way to return closures and iterators, exactly as we first saw at the end of episode 11:
fn adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n // returns one concrete closure type, dispatched statically
}
fn numbers() -> impl Iterator<Item = i32> {
(1..=5).map(|x| x * x) // returns a real iterator, no Box, no dyn
}
fn main() {
let add10 = adder(10);
println!("{}", add10(5)); // 15
let squares: Vec<i32> = numbers().collect();
println!("{squares:?}"); // [1, 4, 9, 16, 25]
}
Every closure has its own unique, compiler-generated type that you cannot type out by name, and the type of (1..=5).map(...) is a similarly unpronounceable Map<...>. impl Trait in return position says "I am returning one specific hidden type that implements this trait, and the compiler knows exactly which -- the caller just does not get to name it". Because it is one specific type, dispatch stays static and there is no allocation and no vtable. This is the single most common place you will reach for impl Trait: returning iterator chains and closures cleanly.
Now the crucial limitation, and the reason dynamic dispatch is not optional but sometimes mandatory. Because impl Trait in return position means "one specific hidden type", you cannot use it to return different types from different branches. The moment two branches want to hand back two genuinely different concrete types -- two different closures are two different types, remember -- impl Trait refuses, and you must erase them behind a trait object with Box<dyn Trait>:
fn make_op(add: bool) -> Box<dyn Fn(i32) -> i32> {
if add {
Box::new(|x| x + 1) // one closure type
} else {
Box::new(|x| x - 1) // a DIFFERENT closure type
}
}
fn main() {
let op = make_op(true);
println!("{}", op(10)); // 11
let op2 = make_op(false);
println!("{}", op2(10)); // 9
}
Try to write that same function as -> impl Fn(i32) -> i32 and the compiler stops you flat, because the two arms produce two incompatible hidden types and impl Trait demands a single one. The Box<dyn Fn(i32) -> i32> is the escape hatch: it erases both closure types behind one uniform trait-object type, so both branches genuinely return the same type as far as the signature is concerned. You pay one indirection per call for that unification -- and that, right there, is the entire static-versus-dynamic trade compressed into a single function. When one hidden type suffices, impl Trait and static dispatch; when you need to unify several types behind one return, Box<dyn Trait> and dynamic dispatch. There is no third option, and that is not a wart -- it is the type system being honest about what it can and cannot know at compile time.
Let me be concrete about when any of this matters, because "dynamic dispatch is slower" gets repeated as folklore and then misapplied. The cost of a vtable call is roughly: one extra memory read to fetch the function pointer, one indirect jump, and the lost opportunity to inline. On modern hardware a single well-predicted indirect call is on the order of a nanosecond, and the memory it reads is almost always hot in cache. So if you make a dyn call once per user request, once per file, once per frame -- you will never, ever measure it. Where it can genuinely bite is a dyn call sitting in the innermost loop of a numeric kernel that runs hundreds of millions of times, where the lost inlining prevents the compiler from vectorising or fusing the surrounding arithmetic. That is a real and specific scenario, and it is also a scenario where you would be profiling anyway. The engineering takeaway: reach for generics by default so you are not leaving that performance on the table for free, but do not contort your architecture around avoiding dyn in code that is not remotely hot. Measure, then optimise -- in that order.
Reach for generics and static dispatch by default: they are fast, they inline, they keep full type information, and most of the time the code-size cost is negligible. Reach for Box<dyn Trait> when you genuinely need a heterogeneous collection (a Vec of different widgets, a list of plugins, a bag of handlers), when you must return different concrete types from different branches, when you want to keep a library's binary size and compile time down across many instantiations, or when the indirection is provably not on a hot path. Use impl Trait arguments to make generic signatures read cleanly, and impl Trait returns to hand back closures and iterator pipelines without boxing. Having said all that: when in doubt, write the generic version first, and switch to a trait object only when a concrete need (mixed types) or a measurement (a hot dispatch) actually points you there ;-)
dyn Trait) compiles to a single function that resolves each call through a vtable at runtime: no code bloat and the ability to mix concrete types behind one interface, at the cost of one indirection per call and the loss of inlining.impl Trait in argument position is just sugar for a generic parameter -- still static dispatch. impl Trait in return position returns one unnameable concrete type (a closure, an iterator) with zero overhead and no boxing.Box<dyn Trait>, which erases them behind one uniform trait-object type -- the static-versus-dynamic trade in a single function.The through-line with episode 15 is simple: generics are your compile-time scalpel, trait objects are your runtime glue. Today just gave you the vocabulary and the cost model to pick between them deliberately, rather than by superstition. Next time we push further into the trait system and start looking at how traits carry types of their own, which opens up a whole new dimension of expressiveness, but one thing at a time ;-)
Three exercises, gentle to chewier as always. Full solutions open the next episode -- have a real go first, because typing this stuff yourself is where it actually sticks.
greet_static<T: Greet>(g: &T) and greet_dyn(g: &dyn Greet) from this episode, then try to build a Vec of two different greeter types for each: a Vec you feed to the generic one, and a Vec<Box<dyn Greet>> you feed to the dyn one. Watch the generic path refuse the mixed vector and the dyn path accept it, and make sure you can say why in one sentence.impl Iterator<Item = u32> that yields the first ten even numbers (2, 4, ... 20), then collect it into a Vec in main and print it. Do it without ever naming the concrete iterator type.fn make_step(up: bool) -> impl Fn(i32) -> i32 that tries to return |x| x + 1 or |x| x - 1 based on up. Confirm it does not compile, then fix it by switching the return type to Box<dyn Fn(i32) -> i32> and boxing both closures. Note in a comment which line the borrow of the two different closure types forced the change.Bedankt voor het meelezen, en tot de volgende keer! ;-)