Trait Objects in Rust and the Mechanics Behind Dynamic Dispatch
Shared Behavior Without Static Types
Trait objects let you write code that works across different types without needing to know the exact type when compiling. This is similar to polymorphism in object-oriented languages, but Rust does it with no runtime garbage collection, safe ownership tracking, and clear performance behavior. It relies on something called dynamic dispatch, which links a method call to the right function while the program is running. Using dyn
creates this behavior and affects how your program is laid out and called in memory, so learning how it works helps you make better choices in how you structure and call shared behavior in Rust.
What Trait Objects Are and How They Work
You get a way to write functions that accept many different types as long as they follow the rules set by a trait. Rust lets you do this with static dispatch through generics, but trait objects are how Rust handles the same idea at runtime. They are shaped to work in a flexible way, while still fitting within Rust’s strict rules around ownership, memory, and compile-time checks. Before looking into what dynamic dispatch does, it helps to get a solid handle on what trait objects are, how they relate to traits, and what kind of structure they carry with them in memory.
What Traits Are Meant To Do
A trait defines behavior. You can think of it as a contract that says, “anything that implements this trait must provide these methods.” Traits don’t hold data, they just describe behavior. That means a trait by itself does nothing until it’s paired with a concrete type. After that, you can implement the trait and make the behavior real.
This is a plain trait with one method. It expects any type that implements it to return a greeting as a string.
Now take two separate types and give them their own versions of this behavior:
Each of these types follows the same trait, but they respond differently. When you use generics, Rust handles this with static dispatch. The compiler figures out what to call while compiling. That works well when the types are known in full. But sometimes they aren’t.
What Happens When You Use dyn
If you want to write a function that works with any type that follows a trait and you don’t know the type ahead of time, that’s where dyn
comes in. It gives you a way to write with shared behavior without needing to name the concrete type.
Now you can call this function using a reference to either English
or French
:
At this point, the types are no longer known in full. The value behind &dyn Greeter
could be anything that implements the trait. You’ve traded the concrete type for shared behavior, and that changes what Rust has to do to make the call work. The compiler sets things up so that when greet()
is called, it knows how to reach the method based on what the object actually is.
That’s the point where dynamic dispatch takes over, but here the focus is just on what changes with dyn
. It gives you shared behavior without binding to one type ahead of time. This helps when building things like plugin systems or parts of a program where different types need to act the same without being the same thing.
The Different Forms of Trait Objects
You can make trait objects using several pointer types. The most basic one is a borrowed reference.
This creates a slice of trait object references. Each element can be a different type as long as it implements Greeter
.
If you need ownership instead of borrowing, Box<dyn Trait>
works well.
This version returns a heap-allocated trait object. It’s one object behind a pointer that carries the method behavior with the data. You get a boxed trait object without needing to work with the type by name.
The Mechanics of Dynamic Dispatch with Trait Objects
When a method is called on a trait object, Rust doesn’t know the exact type at compile time. It has to look it up during program execution. That shift affects how method calls are compiled and what kind of pointer layout gets used behind the scenes. Instead of baking in the method calls during compilation, Rust builds a system that can select the right method while the program runs. This is what makes dynamic dispatch work. It allows one reference to point to any type that follows a shared behavior and still call the right method without needing to match on type or use pattern matching. That behavior is built around fat pointers and vtables, both handled by the compiler automatically.
Trait Objects Are Fat Pointers
When you make a trait object using dyn
, Rust doesn’t give you a simple memory address. It creates a fat pointer that holds two parts. One part points to the data, and the other points to something called a vtable. Together, those two pieces give Rust everything it needs to safely call methods on the object without ever seeing the actual type in the source code. A thin pointer like &i32
just holds one address. A fat pointer like &dyn Trait
holds two. This distinction shows up anytime you work with slices, strings, or trait objects. Slices and strings store a pointer and a length. Trait objects store a pointer to the value and a pointer to the vtable. That extra pointer is what gives the compiler a way to reach method implementations that aren't tied to one static type.
To see it in action, you can use a trait and a trait object like this:
The use_logger
function doesn’t care what the logger is. It just calls the method. Behind the scenes, Rust turns logger.log()
into a two-step process. It loads the vtable from the fat pointer, then looks up the function for log
, then calls it with the data pointer.
That split between the data pointer and the vtable pointer is what makes it work. The data pointer gives access to the value. The vtable pointer gives access to the methods.
What the Vtable Actually Contains
The vtable is built by the compiler. It’s a table of function pointers for the methods that belong to the trait. Every type that implements a trait gets its own vtable. So a FileLogger
has one vtable, and any other type that implements Logger
gets a different one. They all match the same layout, and each function slot lines up with one method in the trait definition.
The compiler fills in the vtable during compilation. It puts in the correct function pointers for each method based on how the type implements the trait. Then, when a trait object is created, Rust stores a pointer to that vtable as part of the fat pointer.
That layout lets Rust call trait methods with no switch logic, no runtime type comparison, and no casting. The call becomes a memory lookup followed by a call through a pointer.
To visualize how it works, think of the fat pointer like this:
So when the method is called, the compiler arranges the call like this:
This lets you write behavior-driven code while the compiler manages the connection between behavior and method implementation.
You can think of this as a type-erased object with just enough information to call methods safely. But you can’t access the data directly or rely on any specific layout. The compiler keeps type safety by only allowing access through the trait methods.
Object Safety Rules
Not all traits can be used with dyn
. There are a few guardrails that Rust uses to keep trait objects valid and prevent unsafe behavior. This is where object safety comes in. If a trait doesn’t follow the rules, it can’t be turned into a trait object. A trait is object-safe if all its methods meet a few conditions. They must not use Self
in a way that needs the concrete type. A method can return Self
if it uses where Self: Sized
. Without that, the compiler doesn’t have enough information to build a trait object, because it still can’t tell how big the type actually is. A method that introduces its own generic type parameters (fn foo\<T>(…)
) makes the trait non-object-safe outright. Keep generic methods in a helper trait or add where Self: Sized
so they’re only callable on concrete types, not through dyn
, but they still can’t appear in the vtable. The method has to work with the erased type, not rely on specific type information to function.
This doesn’t work:
This does:
The first one ties the return type to the exact type that implements it, which breaks the rules. The second one works with dyn
because it only uses &self
.
Object safety exists to keep the call structure stable. The compiler needs to know what method signatures look like at compile time so it can build the vtable. Generic methods and type-dependent returns make that impossible. So they’re blocked from use in trait objects. You can check if a trait is object-safe by trying to use dyn
with it. If the compiler complains, something in the trait breaks the rules. You can usually fix it by changing the method signatures or by returning a trait object such as Box<dyn Trait>
.
Smart Pointers and Trait Objects
Borrowed references like &dyn Trait
let you pass shared behavior without ownership. But trait objects also work with smart pointers when ownership is needed. The most common form is Box<dyn Trait>
. This stores the trait object on the heap and owns the data.
This pattern shows up when you need to return different types behind a shared behavior. Instead of building a large enum or tagging values with match arms, you can return a single boxed trait object. That keeps your return types light and behavior-focused.
Each boxed trait object still carries a fat pointer. It stores both the pointer to the value and the pointer to the vtable. The object sits on the heap, and the Box
manages it. Other smart pointers like Rc<dyn Trait>
or Arc<dyn Trait>
follow the same idea. They allow shared or thread-safe ownership, but the behavior stays the same. Each one carries the fat pointer and uses the vtable for method calls.
This pointer-based structure is what makes trait objects flexible without bringing in runtime cost from full type tracking or heap scanning. The compiler handles the vtable, the fat pointer layout, and the method lookup without asking you to write any of it by hand. When you write the trait and use dyn
, the rest is automatic.
Conclusion
Trait objects work because Rust builds a structure that lets method calls reach the right place without tying them to one specific type. The fat pointer holds both the data and the method table, and that’s enough for the compiler to connect a call to the correct method during runtime. Nothing is guessed or figured out later. It’s all set up in advance through the vtable, and that structure lets you work across types while still keeping memory access safe and direct. It’s one of those parts of the language that feels simple on the surface, but once you’ve seen how it’s connected together, it makes much more sense.