How Rust's dyn Trait builds vtables in memory

How Rust's dyn Trait builds vtables in memory

A self-taught Rust learner, working through the language's official book and Mara Bos's book, set out to compare Rust's approach to polymorphism with C++'s, then published a blog post (with accompanying code on GitHub) walking through what that comparison actually revealed in memory. The post opens with C++'s two classic options: virtual functions, where a vtable pointer lives inside the object and dispatch happens automatically, and CRTP (Curiously Recurring Template Pattern), a compile-time technique the author first learned from a talk by Klaus Iglberger, which avoids vtables entirely at the cost of readability. Rust's equivalent to CRTP is monomorphization, exercised through generics: for fn draw_shape<T: Draw>(shape: T), the compiler generates a separate function per concrete type, with zero runtime cost, though every type must be known at compile time. While testing this, the author checked the size of empty structs like Circle and Square and found std::mem::size_of::<Circle>() returns 0. That surprised them because the C++ standard mandates every object take up at least 1 byte, even when empty, so that two distinct objects always have distinct addresses; Rust instead tracks identity through ownership via the borrow checker, not through memory addresses. In a debug build, two Circle instances still got distinct stack addresses one byte apart (0x7ffdda99aece and 0x7ffdda99aecf), which the author attributes to the compiler assigning zero-sized types a dummy stack slot purely so debuggers can inspect them; in a release build, the two addresses collapsed to the same value. The post then turns to dynamic dispatch, &dyn Draw, and shows that a plain reference &Circle is 8 bytes while &dyn Draw is 16 bytes: a 'wide pointer' made of a data pointer and a vtable pointer, twice the size of a regular pointer. Using std::mem::transmute inside an unsafe block, the author extracts both pointers directly and shows that two different Circle instances share the same vtable pointer while having different data pointers, and that a Square instance gets a different vtable pointer entirely. The post explains why dynamic dispatch is needed at all: a Vec<T> requires every element to be the exact same type and size, so vec![Circle, Square] does not compile; wrapping each value in Box<dyn Draw> fixes this because a Box is always the same size, a wide pointer, letting a single Vec<Box<dyn Draw>> hold Circle and Square together. The author contrasts this with C++, where the choice between static and dynamic dispatch is fixed at the class level (marking a method virtual commits that class to dynamic dispatch), whereas in Rust the choice is made at the call site: the same Circle value can be passed as &Circle for static dispatch or &dyn Draw for dynamic dispatch. A final section demonstrates that Rust keeps one vtable per (type, trait) pair, not one per type: a zero-sized Duck struct implementing both Fly and Swim traits produces a fly_obj and a swim_obj that share the same data pointer but have different vtable pointers, and std::mem::size_of_val(&duck) still returns 0. The extracted text breaks off just as the post moves into a section titled 'Object Safety: Why Not Every Trait Can Be Dyn', so its content on that topic is not available here.

Key facts

  • Rust zero-sized types like empty Circle and Square structs report std::mem::size_of as 0, whereas the C++ standard requires every object to take up at least 1 byte, even when empty.
  • A plain Rust reference &Circle is 8 bytes, while a trait-object reference &dyn Draw is a 16-byte 'wide pointer' combining a data pointer and a vtable pointer.
  • Using unsafe std::mem::transmute, the author shows two Circle instances share one vtable pointer with different data pointers, while a Square instance gets a distinct vtable pointer.
  • Because Vec<T> requires elements of identical size, mixing Circle and Square in one collection requires wrapping each in Box<dyn Draw>, which is why Rust needs dynamic dispatch alongside static dispatch (generics/monomorphization).
  • A zero-sized Duck struct implementing both Fly and Swim gets a separate vtable per (type, trait) pair: fly_obj and swim_obj share a data pointer but carry different vtable pointers, and size_of_val(&duck) remains 0.

Why it matters

The post argues against treating Rust as 'C++ with different syntax': mapping dyn Trait onto C++ virtual functions 1:1 hides what is actually different about Rust's design. By inspecting raw pointer values and struct sizes instead of just reading documentation, the author turns an abstract claim (dynamic dispatch uses vtables) into something verified in memory, showing concretely that Rust's vtable lives outside the object as external static data, unlike C++'s in-object vtable pointer.

Who it affects

Rust learners with a C++ background who are trying to build a mental model for dyn Trait, generics and monomorphization, plus anyone curious about how trait objects, wide pointers and zero-sized types are actually laid out in memory rather than how they are typically described at a high level.

How to use it

The full code for every experiment shown, size checks, address printing, and the unsafe transmute calls that expose data and vtable pointers, is published on GitHub alongside the post, so a reader can run the same snippets with cargo run in both debug and release mode to reproduce the size and address differences described.

How solid is it

The claims rest on the author's own compiler output (size_of results, printed pointer addresses) rather than secondhand description, and the code needed to reproduce every result is public. The extracted text cuts off mid-way through the following 'Object Safety' section, so whatever the post argues there is not covered, and neither the Rust nor C++ compiler version used for the measurements is stated in the visible text.

Risks and caveats

The author is explicit about being a learner, not an authority, and frames the whole piece as personal exploration rather than a reference. The debug-mode address gap between zero-sized-type instances is called out as a debug-only artifact with no compiler guarantee behind it, and the release-mode address collapse should not be read as a general guarantee either, only as what the author observed in that run.

“the compiler makes no guarantees about ZST addresses, and identity is tracked by the borrow checker through ownership, not memory addresses.”

— the post's author