Trait Objects for Polymorphic Code
How to achieve runtime polymorphism in Rust using trait objects, the rules of object safety, the mechanics of dynamic dispatch, and the trade-offs between generics and trait objects.
A trait object lets you write code that works with different types at runtime through a shared interface, without knowing the concrete type at compile time. In Rust, that interface is a trait, and the mechanism is a pointer that bundles both the data and a table of function pointers — a vtable — so the correct method implementation can be called no matter what the underlying type actually is. This capability fills a specific gap in Rust's type system. Generics and trait bounds give you polymorphism that is resolved at compile time, generating separate code for each concrete type. That works perfectly when you know all the types up front, but it cannot handle situations where the set of types is genuinely open-ended or where the exact type is only determined while the program is running. Trait objects solve that.
Defining Trait Objects
A trait object is created by placing the dyn keyword in front of a trait name, typically behind some kind of pointer: &dyn Trait, Box<dyn Trait>, or Arc<dyn Trait>. The dyn keyword makes it explicit that dispatch will be dynamic. Older Rust code sometimes omits dyn and writes just &Trait, but the compiler will warn about that now — always use dyn.
Consider a trait that represents any type capable of returning a description string:
trait Describable {
fn describe(&self) -> String;
}
struct Cat;
struct Moon;
impl Describable for Cat {
fn describe(&self) -> String {
"a small feline".to_string()
}
}
impl Describable for Moon {
fn describe(&self) -> String {
"Earth's natural satellite".to_string()
}
}
Both Cat and Moon implement Describable, but they are completely unrelated types. A function that wants to work with either one, chosen at runtime, would use a trait object:
fn print_description(item: &dyn Describable) {
println!("{}", item.describe());
}
fn main() {
let cat = Cat;
let moon = Moon;
let items: Vec<&dyn Describable> = vec![&cat, &moon];
for item in &items {
print_description(*item);
}
}
The vector holds elements of type &dyn Describable. It can contain references to any type that implements the trait, and they can be mixed freely. The function print_description does not need to know whether it is dealing with a Cat or a Moon; it calls describe through the vtable, and the correct implementation runs.
What the dyn keyword means:
The dyn keyword does not change how the code behaves. It exists to signal, both to the compiler and to anyone reading the code, that method calls on this value go through dynamic dispatch. Without it, a bare &Trait is ambiguous — it could be a reference to some concrete type that implements the trait — and the compiler would reject it.
The pointer inside a trait object is a fat pointer: it contains two addresses. One points to the actual data on the heap or stack, the other points to a vtable that holds a function pointer for each method in the trait, plus information about the type's size, alignment, and destructor. When you call item.describe(), Rust follows the vtable pointer, finds the describe entry, and jumps to that function, passing the data pointer as self.
This is how you get subtype-like polymorphism without inheritance. The trait object acts as a common interface that any implementor can slot into. Because the vtable is generated once per concrete type per trait, there is no duplication of logic — only the pointers vary.
Trait Object Safety
Not every trait can be used as a trait object. A trait must satisfy a set of rules that make it object-safe — officially called "dyn compatible" in recent editions. If a trait is not object-safe, you will see a compiler error when you try to write dyn Trait.
The central constraint is that the compiler must be able to generate a vtable entry for every method, and the vtable entry cannot depend on the concrete type in ways that are unknowable at the call site. This leads to three specific restrictions:
- Methods must not return
Self. A method that returnsSelfwould need to know the concrete type to return it, but the trait object has erased that type. This rules out things like aclonemethod that returns the original type. - Methods must not have generic type parameters. A generic method is really a family of methods, one per concrete type parameter. The vtable would need to hold a function pointer for every possible instantiation, which is impossible. The method itself must be fully monomorphized at the call site, and the trait object cannot do that because it doesn't know the concrete type.
- The trait must not require
Self: Sized. If a trait hasSelf: Sizedas a supertrait bound, then the implementing type must beSized. Trait objects are unsized by nature, so this bound is incompatible.
Object safety errors are common:
A method like fn clone(&self) -> Self will make a trait non-object-safe. If you need to clone trait objects, you can work around this by defining a separate trait that returns Box<dyn Cloneable> instead.
Here is a trait that is not object-safe:
trait NotObjectSafe {
fn clone_self(&self) -> Self;
fn process<T: Display>(&self, value: T);
}
The clone_self method returns Self, violating the first rule. The process method is generic, violating the second. Any attempt to use dyn NotObjectSafe will be rejected at compile time.
A trait can have both object-safe and non-object-safe methods if the non-object-safe ones are placed behind a where Self: Sized clause on the method. Those methods will simply not be available through the trait object, but you can still call them on the concrete type:
trait PartlyObjectSafe {
fn name(&self) -> &str;
fn clone_box(&self) -> Box<dyn PartlyObjectSafe>
where
Self: Sized;
}
impl PartlyObjectSafe for Cat {
fn name(&self) -> &str { "Cat" }
fn clone_box(&self) -> Box<dyn PartlyObjectSafe> {
Box::new(Cat)
}
}
Here name is object-safe; clone_box has a Self: Sized bound, so it is not callable through dyn PartlyObjectSafe but can still be used on concrete Cat values. This pattern allows a form of "cloneable trait objects" without losing object safety for the rest of the trait.
Workarounds exist:
When a trait needs to return Self, you can often redesign it with an associated type or by returning a trait object box. The clone_box method above is one such technique. Another is to use an associated type and let the concrete implementation specify what "Self" means — but that turns the trait away from runtime polymorphism and back toward generics.
Dynamic Dispatch vs Static Dispatch
The distinction between dynamic and static dispatch is at the core of how trait objects differ from generics.
Static dispatch happens when the compiler knows the exact type at every call site and can generate a direct function call. This is what you get with generic functions like fn describe<T: Describable>(item: &T). The compiler produces a separate copy of the function for each concrete type used with it, and inside that copy the call to describe is a normal function call to the specific implementation. The call is fast, can be inlined, and has no runtime overhead beyond the function call itself.
Dynamic dispatch happens when the concrete type is not known until runtime. The compiler generates a single copy of the function that works with any type implementing the trait. It uses the vtable to look up the correct method to call. This indirection costs a few extra CPU instructions on every call, and the call cannot be inlined because the compiler cannot know which function body to inline. That is the runtime cost of flexibility.
Here is a side-by-side comparison using two functions that do the same thing — one with generics (static dispatch) and one with trait objects (dynamic dispatch):
fn describe_static<T: Describable>(item: &T) -> String {
item.describe()
}
fn describe_dynamic(item: &dyn Describable) -> String {
item.describe()
}
Calling describe_static with a Cat creates a version of the function where T = Cat; calling it with a Moon creates a separate version. There is no vtable lookup. Calling describe_dynamic with a &dyn Describable always uses the vtable, regardless of the underlying type.
Dispatch affects inlining:
The compiler cannot inline through a dynamic dispatch call. If a trait method is trivial — returning a constant field, for example — static dispatch lets that tiny function be folded into the caller, eliminating the call entirely. Dynamic dispatch always pays the cost of the function call, however small the method.
The decision between the two is not about which is "better" in absolute terms; it is about what your code needs. Static dispatch gives you maximum performance and allows zero-cost abstractions. Dynamic dispatch gives you flexibility — you can mix types in collections, decide types at runtime, and keep compilation units smaller because you are not generating specialized code for every combination of types.
Trade-offs Between Generics and Trait Objects
This section puts the two approaches side by side so you can make an informed choice. Generics and trait objects are not rivals; they are tools for different constraints. When generics work best:
- You know all the types at compile time.
- You want the compiler to optimize aggressively, inlining and specializing.
- You do not need to store different types together in a single collection.
- You can afford the binary size increase from monomorphization — or that increase is small because you only use a few concrete types. When trait objects work best:
- The set of types is open-ended or determined at runtime (e.g., loading plugins, reading a configuration that maps to different handlers).
- You need a heterogeneous collection — a vector containing different types that share a trait, like a list of drawable UI elements.
- You want to reduce binary size by avoiding many specialized copies of the same function, especially when the trait has many implementors.
- You are willing to pay the small runtime cost of dynamic dispatch in exchange for this flexibility.
Trait objects trade runtime speed for flexibility:
A common mistake is to reach for trait objects by default because they "feel object-oriented." In Rust, you should start with generics unless you have a concrete reason to need runtime polymorphism. Generics give you safety and speed without sacrificing expressiveness.
There is no law that says you must pick one and stick to it. Many Rust programs use generics for most of their code and trait objects for a few specific areas where runtime polymorphism is necessary. The two can coexist: a function can use generics internally and accept a trait object at its public API boundary, or a struct can hold a Box<dyn Trait> while its methods are generic over other types.
Consider a scenario where you are building a logging framework. You want users to be able to plug in their own log targets at runtime — a file writer, a network socket, a ring buffer. You define a LogTarget trait, and the main logger holds a Box<dyn LogTarget>. At the same time, the logger's internal formatting code uses generics over different formatter types because the formatters are known at compile time and benefit from inlining. The hybrid approach keeps the design flexible where it needs to be and fast where it can be.
You can always change your mind:
Because trait objects and generics both rely on the same traits, migrating from one to the other is often mechanical. If you start with a generic function and later discover you need runtime dispatch, you can change fn f<T: Trait>(x: &T) to fn f(x: &dyn Trait) and adjust call sites. The trait implementation itself does not change.
Summary
Trait objects are Rust's mechanism for dynamic polymorphism — they let you write code that operates on any type implementing a trait, with the concrete type resolved at runtime. They do this through fat pointers that pair a data pointer with a vtable, and they are subject to object safety rules that ensure the vtable can be constructed. The choice between static dispatch (generics) and dynamic dispatch (trait objects) is a trade-off: generics offer zero-cost abstraction and inlining; trait objects offer heterogeneous collections and runtime flexibility. The key insight is that Rust does not force you into a single polymorphism model. You get static dispatch by default, with the option to introduce trait objects precisely where runtime decisions matter. This separation makes it possible to write high-performance systems code while still enjoying the expressiveness that object-oriented programmers expect from interfaces and subtype polymorphism.