A trait object in Rust is a fat pointer. That statement is precise, not metaphorical. Every &dyn Trait, Box<dyn Trait>, and *const dyn Trait occupies two machine words: one word holds the address of the concrete value, and the other holds the address of a vtable. The vtable is a static structure generated by the compiler for each concrete type that implements the trait. This layout is the entire mechanism behind dynamic dispatch in Rust.
The Hacker News discussion around the article "Visualizing Rust's Vtables: How dyn Trait Works In Memory" surfaced a common point of confusion: developers coming from C++ expect the vtable pointer to live inside the object itself. In Rust, it does not. The vtable pointer travels with the reference, not with the data. This distinction changes how you reason about memory layout, object size, and API design.
The two words that explain a trait object
A trait object is not a type in the same sense as a struct or an enum. It is a dynamically sized type (DST). The compiler does not know its size at compile time, so you cannot store a dyn Trait directly on the stack or in a struct field without indirection. You must always use a pointer: &dyn Trait, Box<dyn Trait>, Rc<dyn Trait>, or *const dyn Trait.
The pointer itself is the key. A regular pointer in Rust, such as &Circle, is a single machine word containing an address. A trait object pointer, such as &dyn Draw, is two machine words. The first word points to the concrete data. The second word points to the vtable for that concrete type's implementation of the trait.
This two-word representation is why std::mem::size_of::<&dyn Draw>() returns 16 on a 64-bit platform while std::mem::size_of::<&Circle>() returns 8. The data pointer and the vtable pointer together form what the Rust community calls a fat pointer.
The fat pointer does not contain a length, unlike a slice fat pointer which stores a pointer and a length. For trait objects, the metadata is a vtable pointer, not a count. The Pointee trait in the standard library formalises this: for dyn Trait, the Metadata associated type is DynMetadata<dyn Trait>, which is a pointer to the vtable.
What lives in the fat pointer
The first component of a fat pointer is straightforward: it points to the concrete value. That value can be anywhere—stack, heap, or static memory. The pointer is raw address information, nothing more.
The second component points to a vtable. The vtable is a static structure, typically placed in read-only data segments, that contains the function pointers for all the trait's methods, plus some additional metadata required by the language.
The vtable layout is an implementation detail of the Rust compiler, not a stable language guarantee. However, the general structure is well understood from compiler source and documentation. A vtable begins with three entries:
- A pointer to the
drop_in_placeimplementation for the concrete type. This is howBox<dyn Trait>knows how to drop the value when it goes out of scope. - The size of the concrete type, in bytes.
- The alignment of the concrete type, in bytes.
After these three header entries, the vtable contains one function pointer for each method defined in the trait, in declaration order. For traits with supertraits, the vtable may also contain pointers to the supertrait vtables to support trait upcasting coercion.
Each concrete type gets its own vtable for each trait it implements. If a type implements both Draw and Clone, the compiler generates a Draw vtable and a separate Clone vtable. When you coerce a &Circle to &dyn Draw, the fat pointer uses the Draw vtable. When you coerce the same &Circle to &dyn Clone, it uses the Clone vtable.
All instances of the same concrete type share the same vtable. The vtable is per-(type, trait) pair, not per-instance.
Reading a vtable without guessing
The standard library documentation exposes std::ptr::DynMetadata as the conceptual type for trait-object metadata, but the pointer-metadata APIs are still nightly-only in the current stable documentation. That distinction matters: the type is useful vocabulary for understanding what the compiler carries, but it is not a portable stable inspection interface. On stable Rust, std::mem::size_of::<&dyn Draw>() and ordinary coercions let you reason about the representation without extracting metadata.
use std::mem;
trait Speak {
fn speak(&self) -> &str;
}
struct Dog;
impl Speak for Dog {
fn speak(&self) -> &str { "woof" }
}
fn main() {
let dog = Dog;
let trait_obj: &dyn Speak = &dog;
println!("trait object reference: {} bytes", mem::size_of_val(&trait_obj));
println!("concrete reference: {} bytes", mem::size_of::<&Dog>());
}
On a 64-bit target, the first line reports 16 and the second reports 8. The example measures the reference representation, not the size of the Dog value. It is stable and safe, but it does not expose the vtable pointer or its function slots.
Reading the actual function pointers from a vtable is not a stable operation. The vtable layout is not guaranteed across Rust versions or target platforms. Any attempt to read vtable slots by offset is a debugging experiment, not production code. Even comparing vtable pointer values is unreliable: the compiler may duplicate or merge vtables during code generation.
Static dispatch and dynamic dispatch side by side
Static dispatch, using generics, produces a separate monomorphised copy of the function for each concrete type. The compiler knows exactly which draw implementation to call at each call site. There is no vtable, no fat pointer, and no runtime overhead for dispatch.
trait Draw {
fn draw(&self) -> &str;
}
struct Circle;
struct Square;
impl Draw for Circle {
fn draw(&self) -> &str { "Circle" }
}
impl Draw for Square {
fn draw(&self) -> &str { "Square" }
}
// Static dispatch: monomorphised per type.
fn draw_statically<T: Draw>(shape: &T) -> &str {
shape.draw()
}
fn main() {
let c = Circle;
let s = Square;
println!("{}", draw_statically(&c));
println!("{}", draw_statically(&s));
}
The compiler generates draw_statically::<Circle> and draw_statically::<Square> as separate functions. Each function contains a direct call to the corresponding draw implementation.
Dynamic dispatch, using dyn Trait, uses a single function that accepts a fat pointer and dispatches through the vtable.
fn draw_dynamically(shape: &dyn Draw) -> &str {
shape.draw()
}
fn main() {
let c = Circle;
let s = Square;
let shapes: [&dyn Draw; 2] = [&c, &s];
for shape in &shapes {
println!("{}", draw_dynamically(*shape));
}
}
The draw_dynamically function is not monomorphised. There is one copy of the function. Each call to shape.draw() loads the vtable pointer from the fat pointer, finds the method entry for Draw::draw, and calls that function with the data pointer as the self argument.
Dynamic dispatch adds an indirect call and can limit inlining compared with a statically known concrete type. The actual cost depends on the surrounding code, cache behaviour, and whether the compiler can remove or specialise the abstraction. It should be measured in the workload that matters, not reduced to a universal number of loads.
The choice between static and dynamic dispatch is a trade-off between optimisation opportunities and flexibility. Static dispatch enables inlining and devirtualisation when the compiler can see the concrete type, but requires compile-time knowledge of the participating types. Dynamic dispatch allows heterogeneous collections and runtime polymorphism, but gives up some of those opportunities.
Object safety is an API boundary
Not every trait can be used as a trait object. The compiler enforces a set of rules called object safety, also referred to in recent compiler versions as "dyn compatibility". These rules determine whether a trait can be converted to a dyn Trait type.
A trait is dyn-compatible if its methods can be called through a trait object. The most important rules are:
- Methods cannot have generic type parameters unless the method is restricted with
where Self: Sized. A vtable entry must represent one callable signature, while a generic method would require monomorphisation for each type argument. - Methods cannot return
Selfby value unless they are similarly restricted. A trait object does not know the erased concrete size needed for such a return. - A method cannot use
Selfin a position that requires the erased type's size, such as a by-value argument, unless the method is restricted withwhere Self: Sized. - Associated constants are not dyn-compatible. Associated types are allowed when the trait object specifies the associated type, as in
dyn Iterator<Item = u8>.
// Dyn-compatible.
trait Safe {
fn do_something(&self);
fn do_another(&self) -> i32;
}
// Not dyn-compatible: generic method.
trait UnsafeGeneric {
fn generic<T>(&self, x: T);
}
// Not dyn-compatible: associated constant.
trait UnsafeConst {
const KIND: &'static str;
}
// Dyn-compatible when the object supplies Output.
trait HasOutput {
type Output;
fn get(&self) -> Self::Output;
}
// A usable object type can be written as dyn HasOutput<Output = i32>.
The compiler will refuse to coerce a value to &dyn UnsafeGeneric or Box<dyn UnsafeConst>. A trait with an associated type is not automatically rejected; the object type may need to bind that type before it can be used.
The object safety rules are an API boundary, not a performance optimisation. They exist because the compiler cannot generate one callable vtable entry for operations that still require compile-time type information. A vtable does not choose generic method instantiations or provide storage for a size-varying return. An associated type can work when the trait object fixes its value in the object type.
The Rust reference explicitly defines object safety in terms of what operations can be performed on a trait object. A trait object permits "late binding" of methods, dispatched using vtables. If a method cannot be dispatched through a vtable, the trait is not object safe.
A small memory probe in Rust
You can observe the fat pointer layout directly using std::mem::transmute or by casting to a raw pointer and reading the two words. This is an unsafe debugging technique, not a stable API. The following example demonstrates the layout on a 64-bit platform.
use std::mem;
trait Identify {
fn id(&self) -> &str;
}
struct Item(u64);
impl Identify for Item {
fn id(&self) -> &str { "Item" }
}
fn main() {
let item = Item(42);
let trait_ptr: &dyn Identify = &item;
// A fat pointer is two words.
println!("size of &dyn Identify: {} bytes", mem::size_of::<&dyn Identify>());
// On 64-bit: 16 bytes.
// Unsafe inspection: read the two words of the fat pointer.
let (data_ptr, vtable_ptr): (*const (), *const ()) = unsafe {
mem::transmute(trait_ptr)
};
println!("data pointer: {:p}", data_ptr);
println!("vtable pointer: {:p}", vtable_ptr);
// The data pointer points to the `item` variable.
// The vtable pointer points to a static vtable for Item + Identify.
}
The transmute call is unsafe because it bypasses Rust's type system. The language does not promise a stable vtable ABI or a stable ordering for private vtable slots. This code is useful when investigating a particular compiler build, but it is not a general FFI contract. The old core::raw::TraitObject pattern should not be treated as a supported public API.
The pointer-metadata APIs describe the same split more explicitly, but they are still nightly-only in the current standard-library documentation. A nightly experiment can reconstruct a raw trait-object pointer from matching parts:
#![feature(ptr_metadata)]
use std::ptr;
fn inspect_again(trait_ptr: *const dyn Identify) {
let metadata = ptr::metadata(trait_ptr);
let data = trait_ptr as *const ();
let reconstructed: *const dyn Identify = ptr::from_raw_parts(data, metadata);
// The metadata must belong to the same erased trait-object type.
assert_eq!(trait_ptr as *const (), reconstructed as *const ());
}
The functions are safe in the narrow sense that constructing the raw pointer does not dereference it, but a caller must still ensure that the data pointer and metadata describe a valid Identify object before dereferencing the result. That is a compiler-experiment boundary, not a stable application interface.
Rules for using dyn Trait deliberately
Use dyn Trait when you need runtime polymorphism in a collection or when the set of types is not known at compile time. A Vec<Box<dyn Draw>> can hold circles, squares, and triangles together. A Vec<Circle> cannot.
Avoid dyn Trait when the concrete type is known at compile time and you do not need heterogeneous storage. Static dispatch through generics produces faster code and enables inlining.
When designing a trait that you intend to use as a trait object, ensure object safety from the start. Generic methods and Self-returning methods are the most common blockers. If you need a trait to be object safe and also support generic methods, consider splitting the trait into an object-safe core and a separate generic extension trait.
The vtable pointer in a fat pointer is not a stable ABI. Different Rust versions or different compiler flags may change the vtable layout. Code that relies on a specific vtable offset is fragile and should be confined to unsafe debugging or specialised crates that track compiler internals.
The DynMetadata API provides stable access to size and alignment. Use it when you need to know the size of the concrete type behind a trait object. Do not attempt to read the function pointer slots directly in production code.
The cost of dynamic dispatch is two additional memory indirections per method call. In performance-critical code paths, consider using an enum over a fixed set of types instead of dyn Trait. An enum allows static dispatch through pattern matching while still supporting heterogeneous values.
Object safety is a property of the trait definition, not the implementation. You cannot make an object-unsafe trait object-safe by changing the implementation. The trait's method signatures must satisfy the object safety rules for any dyn Trait to exist. If you control the trait, design it to be object safe. If you do not control the trait, use generics or wrapper types.
The fat pointer representation of trait objects is one of Rust's most elegant design decisions. It separates the mechanism of polymorphism from the data itself, keeps objects small, and enables zero-cost abstractions where static dispatch is sufficient. A trait object is just a pointer and a vtable. Everything else follows from that fact.
Originally published on Dispatch.
Top comments (0)