What is actually inside a trait object?

Rust’s dyn Trait is one of those features most developers use daily and few have fully visualised. Sofia Belen’s write-up, Visualizing Rust’s Vtables, does the dissection, and it is a good read for anyone coming to Rust from C++ who keeps trying to map one language onto the other. Spoiler from her conclusion: that mapping is a trap. The two languages solve the same problem with genuinely different philosophies.

Her experiment starts with the classic shapes example: a vector of circles and squares, and a draw() call on each. In C++ you would reach for virtual functions, where the vtable pointer lives inside every object. In Rust the first instinct is generics, and she shows what monomorphization does: the compiler stamps out a separate copy of the function for each concrete type. Zero runtime cost, but the types must be known at compile time, and every new type means more generated code.

The wide pointer

The moment generics stop working is the moment you want a Vec of mixed shapes. A vector needs every element to be the same size, and two unrelated structs have no reason to match. That is where dyn Trait enters, and it enters as a wide pointer: two machine words instead of one. One points at the data, the other points at a vtable, a static table of the trait’s method implementations for that concrete type.

The size check is the cleanest proof. A normal reference is 8 bytes on a 64-bit target. A &dyn Draw is 16. Belen goes further and uses std::mem::transmute to split a trait object into its two raw halves and print them, which shows something satisfying: every instance of the same type shares one vtable pointer, while the data pointer differs per instance. The vtable is not stored in the object at all. It is static data the compiler pairs with the object only when you ask for dynamic dispatch.

She pushes it further with a type that implements two traits. A Duck that both Fly and Swim produces two different vtable pointers from the same data pointer. One vtable per (type, trait) pair. The duck itself never changes; the pairing is created at the call site.

Dispatch is a call-site decision in Rust

This is the part I find most interesting, because it inverts a C++ habit. In C++, whether a method dispatches virtually is decided at the class level: mark it virtual and every instance carries the vtable pointer whether the current code needs polymorphism or not. In Rust, Circle is just Circle. You get static dispatch by passing &Circle and dynamic dispatch by passing &dyn Draw. The overhead appears only where you explicitly ask for it.

Why some traits cannot be dyn

The article closes with object safety, the rules that decide whether a trait can become a trait object. Two cases matter:

The C++ comparison is instructive here. The generic-method restriction bites C++ too: a template cannot be virtual for the same unbounded-vtable reason. The Self-by-value restriction, though, is pure Rust, and it exists because Rust works with values directly while C++ virtual dispatch works through pointers.

Practical takeaways for day-to-day Rust

Understanding the layout pays off in concrete ways. When you see Box<dyn Error> in a return type, you now know that error handling with trait objects is paying two words per object plus an indirection on every method call, where an enum-based error type would pay nothing at dispatch time and could even be smaller. That is not a reason to avoid trait objects; it is a reason to make the trade deliberately.

The shared-vtable detail explains a common observation too: converting between trait objects, upcasting from a subtrait to a supertrait, needed compiler support precisely because the vtable for (Type, SubTrait) is a different static object from (Type, SuperTrait), and reshuffling those pointers is not something user code can do safely. The vtable is compiler-generated static data, and only the compiler can hand out new pairings.

Object safety also stops being an arbitrary compiler complaint once you see the vtable. When the compiler rejects Box<dyn Clone>, it is not being fussy; the vtable genuinely cannot answer “how big is the thing I am about to return?” The workarounds now make sense: Box<dyn Clone> style traits get reworked to return Box<Self>-like boxed results, where the boxed pointer hides the size question, or you reach for an enum when the type set is closed and small.

If you are interviewing Rust developers or being interviewed yourself, this material is also a cut above the usual questions. Asking a candidate to explain why &dyn Trait is two words, and why that cost appears only at the reference rather than the struct, separates people who have used trait objects from people who have thought about them.

A detour worth taking

There is also a nice aside on zero-sized types. A Circle struct with no fields has size_of equal to 0 in Rust, which surprises C++ developers, since the C++ standard guarantees every object occupies at least one byte so that distinct objects get distinct addresses. Rust tracks identity through ownership and the borrow checker rather than through addresses. Even the odd result where two ZSTs print different stack addresses in debug builds turns out to be a debugger convenience, and the addresses collapse in release mode.

The whole piece comes with a GitHub repo of the experiments, so you can run the transmute tricks yourself. If you have ever described dyn Trait as “a pointer to a vtable” without being able to sketch the memory layout on a whiteboard, this is the hour it takes to fix that.

Leave a Reply

Your email address will not be published. Required fields are marked *