dyn Trait gives you polymorphism resolved at runtime;Vec cannot hold two different concrete types at once, and how Box<dyn Trait> fixes that;&dyn Trait as a function parameter and when that is the right call;Box from episode 12;Learn Rust Series):Welcome to Phase 2, which is all about Rust's trait system -- the language's main tool for abstraction, and honestly the part that makes Rust feel like Rust once it clicks. We kick the phase off with the feature that trips up more or less every newcomer sooner or later: trait objects. Back in episode 8 we met generics, and generics give you polymorphism that is resolved at compile time -- the compiler stamps out a fresh, specialised copy of your code for every concrete type you use. Trait objects give you the other flavour: polymorphism resolved at runtime, where a single piece of compiled code works over many different types, and the exact method to call is decided while the program is actually running. Knowing which of the two to reach for is one of those quiet skills that separates someone who "writes Rust" from a real Rustacean ;-)
Having said that, before we open the new topic we always clear last episode's homework first. So let us do that.
Episode 14 was our mini project -- a command-line to-do app that stored tasks in a text file. There were three exercises, and here is how each one lands, with full code you can paste straight into the project and run.
Exercise 1 asked for a clear command that wipes the list by deleting the file, and -- crucially -- treats an already-missing file as "already clear" rather than as an error. The trick is to reuse the exact match guard idea from load_tasks: match the specific NotFound case and swallow it, forward everything else honestly:
use std::fs;
use std::io;
fn clear(path: &str) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), // already clear
Err(e) => Err(e),
}
}
The insight is that "the file is not there" is not a failure of clear -- it is clear already being done for us. A permissions error or a broken disk is a real failure, and those fall through to the last arm and get returned to the caller. Same posture as episode 6: be specific about what you can recover from, and pass on the rest.
Exercise 2 wanted list to hide completed tasks (the ones starting with [x]) by default, but show everything when the user passes a second argument all. The exercise specifically asked you to use a filter on the iterator rather than an if inside the loop, and that constraint is the whole lesson -- filtering belongs in the iterator chain, not smeared across the loop body:
fn list_tasks(tasks: &[String], show_all: bool) {
for (i, task) in tasks
.iter()
.enumerate()
.filter(|(_, t)| show_all || !t.starts_with("[x]"))
{
println!("{i}: {task}");
}
}
Notice we enumerate() before we filter(), not after. That ordering matters: it means the index i is the task's real position in the full list, so todo done 3 still refers to the same task whether or not the done items are being shown. If you filtered first and enumerated second, the numbers would silently renumber themselves every time the view changed -- a genuinely nasty little bug. The filter closure keeps a row when show_all is true or when the task does not start with [x], which is exactly the "hide done unless asked" rule spelled out as one boolean.
Exercise 3 was the meatiest: replace the [ ]/[x] string trick with a proper struct Task { done: bool, text: String }, then write one function that serialises a Task into a single line and another that parses a line back into a Task. This is your first hand-rolled taste of serialisation, and it is worth doing by hand once before any library ever does it for you:
struct Task {
done: bool,
text: String,
}
fn serialize(task: &Task) -> String {
let mark = if task.done { "x" } else { " " };
format!("[{mark}] {}", task.text)
}
fn parse(line: &str) -> Task {
let done = line.starts_with("[x]");
let text = line.get(4..).unwrap_or("").to_string();
Task { done, text }
}
serialize picks the marker character from the done flag and glues it in front of the text -- the mirror of what the app was doing inline before, now with a name and a type. parse reads the flag back out of the first three characters and takes everything from byte 4 onward as the text (that is the [x] prefix -- three bracket characters plus one space). We reach for line.get(4..) in stead of line[4..] on purpose: slicing with get returns an Option, so a malformed short line yields None and we fall back to an empty string with unwrap_or(""), rather than panicking on a line that is somehow too short. Round-tripping a Task through serialize and then parse gives you the same done flag and text back, which is the property that makes it a real (if tiny) serialisation format. Right -- homework cleared, on to the actual topic ;-)
Generics are wonderful, but there is one thing they fundamentally cannot do, and running head-first into that wall is the best way to understand why trait objects exist at all. A generic type parameter gets filled in with one concrete type per use. When you write Vec<T> and set T = Circle, you have a vector of circles, full stop -- every element is a Circle, all the same size, all the same layout. You cannot put a Circle in slot zero and a Square in slot one, even if both types implement the exact same trait, because Vec<T> demands a single T.
Here is the trait and two types that implement it. A shape can tell you its area and its name:
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle { radius: f64 }
struct Square { side: 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" }
}
Both Circle and Square are perfectly good Shapes. But a Circle is one f64 wide and a Square is one f64 wide too -- and even when the sizes happen to match, in general two types implementing a trait have different sizes and different memory layouts. So Vec<Shape> is not even a legal type: the compiler cannot lay out a vector when it has no idea how many bytes each element takes. We need a way of saying "some value of some type that implements Shape, and I will not know which until the program runs". That "I will not know which until runtime" is precisely the thing generics refuse to express, and precisely the thing trait objects were invented for.
That way is the trait object, written dyn Shape. Because different shapes can have different sizes, a trait object is what Rust calls a dynamically sized type -- you can never hold one directly on the stack, because "how big is a dyn Shape" has no fixed answer. Instead you always hold it behind a pointer, and the most common pointer for owning one is our friend Box from episode 12. A Box<dyn Shape> is a single, uniformly sized handle that can point at any shape whatsoever, which means you can finally collect a heterogeneous mix of them in one Vec:
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle { radius: f64 }
struct Square { side: 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" }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Square { side: 2.0 }),
];
for shape in &shapes {
println!("{}: area {:.3}", shape.name(), shape.area());
}
}
The Vec<Box<dyn Shape>> holds two boxes -- one pointing at a circle on the heap, one pointing at a square -- and the loop calls area() and name() on each one without knowing or caring which concrete type is hiding behind the box. That is dynamic dispatch: the exact method that runs is looked up at runtime, per value, based on what the box is actually pointing at. If you come from Python, this is the closest Rust gets to just throwing a bunch of different objects into a list and calling a shared method on each -- except here the compiler has already checked, at compile time, that every single element genuinely is a Shape. You get the flexibility of duck typing with none of the "oops, that object did not have that method" surprises at runtime. Go programmers will recognise the shape of this too: a Box<dyn Shape> is very close in spirit to a Go interface value, which likewise carries a pointer to data alongside a table of methods.
So how on earth does that loop know to run Circle::area for the first element and Square::area for the second, when the type has been erased behind dyn? The answer lives in the shape of the pointer itself, and this is the one bit of genuine machinery I want you to carry away from this episode.
A normal &Circle is a thin pointer: a single machine word holding the address of the data, nothing more. A trait-object pointer like &dyn Shape or Box<dyn Shape> is different -- it is a fat pointer, two machine words wide. The first word points at the data (the actual Circle or Square bytes). The second word points at a vtable (short for "virtual method table"), a small static table the compiler generates once per (type, trait) pair. That table holds function pointers for each of the trait's methods -- one slot for area, one for name -- plus a little bookkeeping like the value's size and how to drop it. When you call shape.area(), Rust follows the vtable pointer, reads the area slot, and calls whatever function pointer it finds there. For a box pointing at a Circle, that slot points at Circle::area; for a Square, at Square::area. Same call site, different function, chosen at runtime.
That mechanism is not free, and it is worth being honest about the cost. Every dynamic call pays for one extra pointer indirection (jump through the vtable to find the function), and -- usually the bigger deal -- the compiler cannot inline a call it will only resolve at runtime, so it also loses the cascade of optimisations that inlining unlocks. With generics, by contrast, the compiler knows the concrete type at each call site, dispatches statically, and can inline freely. That trade -- a touch of runtime cost and lost inlining, in exchange for the freedom to mix types behind one interface -- is the entire story of static versus dynamic dispatch, and we are going to put the two of them head to head properly very soon.
You do not always need a Box to work with a trait object. When a function just wants to accept "anything that implements this trait" and only borrows it for the duration of the call, a borrowed &dyn Trait is the lightest tool for the job -- no heap allocation, no ownership transfer:
trait Draw {
fn draw(&self) -> String;
}
struct Button {
label: String,
}
impl Draw for Button {
fn draw(&self) -> String {
format!("[ {} ]", self.label)
}
}
fn render(item: &dyn Draw) {
println!("{}", item.draw());
}
fn main() {
let ok = Button { label: String::from("OK") };
render(&ok); // coerced into a &dyn Draw at the call site
}
Now compare that render with a generic version, fn render<T: Draw>(item: &T). They look almost identical at the call site, but they compile to genuinely different things. The generic version is monomorphized: the compiler produces a separate, specialised copy of render for every concrete T you ever call it with, and each copy dispatches statically and can inline the draw call. The &dyn Draw version compiles to exactly one render function in the whole binary, and it dispatches dynamically through the vtable every time. Neither is "better" -- the generic one tends to be faster but bloats the binary with copies, the trait-object one is leaner in code size but pays at each call. This is the very comparison next episode is built around, so keep that pair in the back of your mind.
The pattern where trait objects truly earn their keep is a heterogeneous collection of behaviours. The textbook example -- and it is textbook for a good reason -- is a little UI toolkit that holds a list of widgets, each of which draws itself in its own way:
trait Draw {
fn draw(&self) -> String;
}
struct Button { label: String }
struct Checkbox { checked: bool }
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("[ ]") }
}
}
fn main() {
let ui: Vec<Box<dyn Draw>> = vec![
Box::new(Button { label: String::from("Save") }),
Box::new(Checkbox { checked: true }),
Box::new(Button { label: String::from("Cancel") }),
];
for widget in &ui {
println!("{}", widget.draw());
}
}
Look at how little the render loop knows. It walks a Vec<Box<dyn Draw>> and asks each widget to draw() itself, blissfully unaware of whether it is holding a button or a checkbox. Add a Slider type tomorrow -- implement Draw for it and push one more Box::new(Slider { .. }) into the ui vector -- and the loop does not change by a single character. This is the classic extensibility win: the code that uses the widgets is written once and stays frozen, while the set of widget types can keep growing forever. Try to do the same with a plain enum of widget kinds and you would be editing that match in the loop every time you add a variant. Trait objects push the "which kind is it" decision down into the vtable, where you never have to touch it by hand.
Once you have a Vec<Box<dyn Shape>>, the good news is that it behaves like any other collection -- slices, iterators, the works. Summing the areas of a mixed bag of shapes is just a map and a sum, exactly the iterator adapters we met in episode 11, working unchanged over trait objects:
trait Shape { fn area(&self) -> f64; }
struct Circle { radius: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } }
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Circle { radius: 2.0 }),
];
println!("{:.2}", total_area(&shapes)); // 15.71
}
There is a small idiomatic detail worth pointing at: total_area takes &[Box<dyn Shape>], a slice of boxes, rather than &Vec<Box<dyn Shape>>. That is the same "ask for the most general read-only view you can" habit from the last episode -- a slice accepts any contiguous run of boxes, and thanks to deref coercion you can pass a &Vec where a slice is wanted for free. Inside the function, .map(|s| s.area()) calls area dynamically on each box and .sum() adds the results. The iterator machinery does not know or care that dispatch is dynamic here; it composes exactly the way it did over plain numbers. That kind of "everything just fits together" is what makes the whole language feel coherent rather than like a bag of special cases.
Now for the catch, because there is always a catch. Not every trait can be turned into a dyn Trait. A trait is object safe (the modern docs say "dyn compatible") only if the compiler can actually build a vtable for it, and if it cannot, dyn YourTrait will simply refuse to compile. The two rules that bite most often are these: a method usable through a trait object must take self by reference (or by Box<Self>), and it must not return Self or use generic type parameters of its own.
trait NotObjectSafe {
fn make() -> Self; // no `self` at all, and it returns Self
}
fn main() {
// let x: Box = /* ... */; // would NOT compile
println!("a method with no self and a -> Self return is not object safe");
}
Why the restriction? Think back to the vtable. When you hold a Box<dyn NotObjectSafe>, all you have is a data pointer and a table of function pointers -- you have forgotten the concrete type. A method like fn make() -> Self needs to know the concrete type to even exist (which Self would it construct? and how big is the value it returns?), and there is no self value to dispatch on in the first place, so it cannot live in a vtable at all. The intuition is enough for now, and it is a genuinely useful one: a trait object can only offer methods that make sense when all you are holding is a pointer plus a vtable. If a method needs the real, concrete type -- to build one, to return one by value, to be generic over another type -- then it cannot be dispatched dynamically, and the trait is not object safe. We will meet the precise rulebook again later in the phase when we start designing our own APIs; for today, recognising the smell is plenty.
Let me leave you with the practical rule of thumb, because "generics or trait objects" is a decision you will make constantly. Reach for generics (static dispatch) by default: they are faster, they inline, and they keep full type information. Reach for trait objects (Box<dyn Trait>, &dyn Trait) when you specifically need to store or handle a mix of concrete types behind one interface -- a Vec of different widgets, a plugin list, a heterogeneous collection of anything -- or when monomorphization would explode your binary into hundreds of near-identical copies. In short: same type used many times, lean on generics; different types unified behind one interface at runtime, lean on trait objects. Get that instinct right and you have got the core of Rust's abstraction story in your pocket ;-)
Vec<T> holds one concrete type; to mix types behind a shared trait you need a trait object, dyn Trait, held behind a pointer such as Box<dyn Trait>.&dyn Trait is the borrowed, allocation-free way to accept "anything implementing this trait" as a parameter, versus a generic <T: Trait> which monomorphizes and dispatches statically.self by value, return Self, or are generic cannot live in a vtable, so such traits cannot become dyn.None of this replaces generics -- it complements them. Generics are your compile-time scalpel; trait objects are your runtime glue for the moments when the exact type genuinely is not known until the program runs. Next time we take the two approaches and measure them against each other properly, so you can feel why one is faster and the other more flexible, rather than just taking my word for it.
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.
Triangle { base: f64, height: f64 } type, implement Shape for it, and push one into the Vec<Box<dyn Shape>> alongside the circle and square. Notice you change one line at the call site and nothing in the loop.Draw toolkit with a Slider { value: u8 } widget whose draw renders something like <value>/255, and render a mixed Vec<Box<dyn Draw>> containing a button, a checkbox and your slider.fn render_dyn(item: &dyn Draw) and fn render_generic<T: Draw>(item: &T) -- then reason on paper about which one could go into a Vec of mixed widget types and why the other one cannot. (Hint: think about what monomorphization would have to produce.)Tot de volgende keer, en veel plezier met coderen! ;-)