One of the core principles of object-oriented programming is polymorphism: the ability for code to work with values of different types through a common interface. Rust supports polymorphism in two ways: through static dispatch (using generics) and dynamic dispatch (using trait objects).
While generics are often preferred for their performance, trait objects provide a powerful and flexible way to handle situations where the specific type of a value isn’t known at compile time.
What is a Trait Object?
A trait object is a special kind of pointer that allows you to treat different concrete types that implement the same trait as a single type. It consists of two parts:
- A pointer to the actual data (the instance of the struct).
- A pointer to a virtual method table (vtable), which contains the addresses of the concrete methods for that type.
Because the compiler doesn’t know the size of the concrete type at compile time, a trait object must always be behind a pointer, such as &, Box, or Rc. The syntax for a trait object is dyn Trait, for example &dyn MyTrait or Box<dyn MyTrait>.
When you call a method on a trait object, Rust uses the vtable to look up the correct method address at runtime. This process is called dynamic dispatch.
Use Case: A Heterogeneous Collection
The most common use case for trait objects is to create a collection of different types that all share a common behavior.
Let’s imagine a simple GUI application where we want to render various UI elements. Each element can be drawn, but they are all different types.
// 1. Define the common behavior with a trait.
pub trait Drawable {
fn draw(&self);
}
// 2. Create different types that implement the trait.
pub struct Button {
pub label: String,
}
impl Drawable for Button {
fn draw(&self) {
println!("Drawing a button with label: '{}'", self.label);
}
}
pub struct Image {
pub src: String,
}
impl Drawable for Image {
fn draw(&self) {
println!("Drawing an image from source: '{}'", self.src);
}
}
// 3. Use a vector of trait objects to hold different types.
fn main() {
let ui_elements: Vec<Box<dyn Drawable>> = vec![
Box::new(Button {
label: String::from("Submit"),
}),
Box::new(Image {
src: String::from("/icon.png"),
}),
];
// 4. Call the method on each element, dispatching dynamically.
for element in ui_elements {
element.draw();
}
}
In this example, the ui_elements vector can hold both Buttons and Images because they are stored as Box<dyn Drawable> trait objects. The for loop doesn’t need to know the concrete type of each element; it just calls the draw method, and Rust figures out the right one to run at runtime.
Static vs. Dynamic Dispatch
It’s important to understand the trade-offs between using trait objects (dynamic dispatch) and generics (static dispatch).
Static Dispatch with Generics:
- How it works:
fn process<T: Drawable>(item: T) { ... } - The compiler generates a specialized version of the function for each concrete type used (monomorphization).
- Pros: Fast. Method calls are resolved at compile time and can be inlined.
- Cons: Can lead to larger binary sizes. Cannot be used for heterogeneous collections like the example above.
Dynamic Dispatch with Trait Objects:
- How it works:
fn process(item: &dyn Drawable) { ... } - The method call is looked up at runtime using a vtable.
- Pros: Allows for heterogeneous collections. Reduces binary size as code is not duplicated.
- Cons: Incurs a small runtime performance cost due to pointer indirection and vtable lookup. Prevents some compiler optimizations like inlining.
Object Safety
Not all traits can be made into trait objects. A trait must be object-safe. A trait is object-safe if both of the following are true:
- The trait does not require
Self: Sized. - All of its methods are object-safe.
A method is object-safe if:
- It does not have any generic type parameters.
- Its return type is not
Self. - The first parameter is not
selfby value (self: Self).
For example, the Clone trait is not object-safe because its clone method returns Self. If you had a &dyn Clone, the compiler wouldn’t know the size of the concrete type to allocate for the returned value.
// This is not object-safe because clone() returns Self.
pub trait Clone {
fn clone(&self) -> Self;
}
// This would not compile:
// let clonable: &dyn Clone = &5;
Conclusion
Trait objects are a key feature for enabling OOP-like patterns in Rust. They provide the flexibility to work with different types through a common interface when the concrete types aren’t known at compile time. While there is a minor performance cost compared to static dispatch with generics, they are an indispensable tool for building flexible and extensible applications.