20.4 Trait Objects: Polymorphism Without Inheritance
Rust uses traits to achieve polymorphism. Although static dispatch via generics is often preferred for performance, Rust also supports trait objects for dynamic dispatch. This is loosely analogous to virtual functions in OOP.
20.4.1 Key Features of Trait Objects
- Dynamic Dispatch: Calls on a trait object are resolved at runtime via a vtable-like mechanism.
- Flexible Implementations: Different structs can implement the same trait(s) without a shared base class.
- Use Cases: Good for open-ended sets of types, where new types implementing a trait can be added without changing existing code.
20.4.2 Syntax for Trait Objects
In Rust, a trait object must be placed behind a pointer type because the size of the underlying concrete type is unknown at compile time. Common forms include:
&dyn Trait
for a reference to a trait object.Box<dyn Trait>
for a heap-allocated trait object.
For example:
#![allow(unused)] fn main() { trait Animal { fn speak(&self); } struct Dog; impl Animal for Dog { fn speak(&self) { println!("Woof!"); } } fn example(animal: &dyn Animal) { animal.speak(); } let dog = Dog; example(&dog); // We pass a reference to a type implementing Animal }
Or:
#![allow(unused)] fn main() { trait Animal { fn speak(&self); } struct Cat; impl Animal for Cat { fn speak(&self) { println!("Meow!"); } } let my_animal: Box<dyn Animal> = Box::new(Cat); my_animal.speak(); }
Here, Box<dyn Animal>
is a pointer on the heap referencing a type that implements the Animal
trait.
20.4.3 How Trait Objects Work Internally
Even though the handle (the part you store in a variable) of a trait object is effectively two pointers in size—one pointer to the data and one pointer to the vtable—the actual data the trait object references can be any size. The compiler cannot store an arbitrary-size type inline, so Rust requires references or boxes:
- A pointer to the underlying concrete type (e.g., a struct instance).
- A pointer to a vtable (virtual method table), containing function pointers for the methods in the trait.
When you call a method on dyn Trait
, Rust looks up the correct function pointer in the vtable and invokes it. This supports polymorphism without compile-time knowledge of the exact type, but does come with runtime dispatch overhead.
Example Using Trait Objects
trait Animal { fn speak(&self); } struct Dog; struct Cat; impl Animal for Dog { fn speak(&self) { println!("Woof!"); } } impl Animal for Cat { fn speak(&self) { println!("Meow!"); } } fn main() { let animals: Vec<Box<dyn Animal>> = vec![ Box::new(Dog), Box::new(Cat), ]; for animal in animals { animal.speak(); // Resolved at runtime via the vtable } }
C++ Comparison:
#include <iostream>
#include <memory>
#include <vector>
class Animal {
public:
virtual ~Animal() {}
virtual void speak() const = 0;
};
class Dog : public Animal {
public:
void speak() const override { std::cout << "Woof!\n"; }
};
class Cat : public Animal {
public:
void speak() const override { std::cout << "Meow!\n"; }
};
int main() {
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>());
animals.push_back(std::make_unique<Cat>());
for (const auto& animal : animals) {
animal->speak();
}
}
Here, Rust avoids class-based hierarchies by letting each struct implement the Animal
trait. Polymorphism is still achieved via trait objects, but without the baggage of inheritance chains.