20.4 Trait Objects: Runtime Polymorphism
Rust achieves runtime polymorphism, similar to C++ virtual functions, through trait objects. This allows code to operate on values of different concrete types that implement the same trait, without knowing the specific type until runtime.
20.4.1 Syntax and Usage: dyn Trait
Trait objects are referenced using the dyn
keyword followed by the trait name (e.g., dyn Drawable
). Because the size of the concrete type underlying a trait object isn’t known at compile time, trait objects must always be used behind a pointer, such as:
&dyn Trait
: A shared reference to a trait object.&mut dyn Trait
: A mutable reference to a trait object.Box<dyn Trait>
: An owned, heap-allocated trait object (similar tostd::unique_ptr<Base>
in C++).- Other pointer types like
Rc<dyn Trait>
orArc<dyn Trait>
(for shared ownership).
Example using a reference:
trait Speaker { fn speak(&self); } struct Dog; impl Speaker for Dog { fn speak(&self) { println!("Woof!"); } } struct Human; impl Speaker for Human { fn speak(&self) { println!("Hello!"); } } // Function accepts any type implementing Speaker via a shared reference fn make_speak(speaker: &dyn Speaker) { speaker.speak(); // Runtime dispatch: calls the correct implementation } fn main() { let dog = Dog; let person = Human; make_speak(&dog); // Calls Dog::speak make_speak(&person); // Calls Human::speak }
Example using Box
for owned objects:
trait Speaker { fn speak(&self); } struct Cat; impl Speaker for Cat { fn speak(&self) { println!("Meow!"); } } fn main() { // Create a heap-allocated Cat, accessed via a trait object pointer let animal: Box<dyn Speaker> = Box::new(Cat); animal.speak(); // Runtime dispatch }
20.4.2 Internal Mechanism: Fat Pointers and Vtables
A trait object pointer (like &dyn Speaker
or Box<dyn Speaker>
) is a fat pointer. It contains two pieces of information:
- A pointer to the instance’s data (e.g., the memory holding the
Dog
orCat
struct). - A pointer to a virtual table (vtable) specific to the combination of the trait and the concrete type (e.g., the vtable for
Dog
’s implementation ofSpeaker
).
The vtable is essentially an array of function pointers, one for each method in the trait, pointing to the concrete type’s implementation of those methods. When a method like speaker.speak()
is called via a trait object, the program:
- Follows the vtable pointer in the fat pointer to find the vtable.
- Looks up the appropriate function pointer for the
speak
method within that vtable. - Calls the function using that pointer, passing the data pointer as the
self
argument.
This lookup and indirect call happen at runtime, enabling dynamic dispatch.
Example: Heterogeneous Collection
Trait objects allow storing different types that implement the same trait within a single collection, a common OOP pattern.
trait Drawable { fn draw(&self); } struct Circle { radius: f64 } impl Drawable for Circle { fn draw(&self) { println!("Drawing a circle with radius {}", self.radius); } } struct Square { side: f64 } impl Drawable for Square { fn draw(&self) { println!("Drawing a square with side {}", self.side); } } fn main() { // A vector holding different shapes, all implementing Drawable let shapes: Vec<Box<dyn Drawable>> = vec![ Box::new(Circle { radius: 1.0 }), Box::new(Square { side: 2.0 }), Box::new(Circle { radius: 3.0 }), ]; // Iterate and call the draw method via dynamic dispatch for shape in shapes { shape.draw(); } }
Comparison with C++:
This Rust pattern closely mirrors using base class pointers and virtual functions in C++:
#include <iostream>
#include <vector>
#include <memory> // For std::unique_ptr
// Abstract base class (like a trait)
class Drawable {
public:
virtual ~Drawable() = default; // Essential virtual destructor
virtual void draw() const = 0; // Pure virtual function (interface)
};
// Derived class (like a struct implementing the trait)
class Circle : public Drawable {
double radius;
public:
Circle(double r) : radius(r) {}
void draw() const override {
std::cout << "Drawing a circle with radius " << radius << std::endl;
}
};
// Another derived class
class Square : public Drawable {
double side;
public:
Square(double s) : side(s) {}
void draw() const override {
std::cout << "Drawing a square with side " << side << std::endl;
}
};
int main() {
// Vector holding smart pointers to the base class
std::vector<std::unique_ptr<Drawable>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Square>(2.0));
shapes.push_back(std::make_unique<Circle>(3.0));
// Iterate and call the virtual method
for (const auto& shape : shapes) {
shape->draw(); // Dynamic dispatch via vtable
}
return 0;
}
Both achieve runtime polymorphism, allowing different types conforming to a common interface to be handled uniformly. Rust uses traits and dyn Trait
, while C++ uses inheritance and virtual
.
20.4.3 Object Safety
Not all traits can be made into trait objects. A trait must be object-safe. The key rules ensuring object safety are:
- Receiver Type: All methods must have a receiver (
self
,&self
, or&mut self
) as their first parameter, or be explicitly callable without requiringSelf
(e.g., usingwhere Self: Sized
). - No
Self
Return Type: Methods cannot return the concrete typeSelf
. - No Generic Parameters: Methods cannot have generic type parameters.
These rules ensure that the compiler can construct a valid vtable. For example, a method returning Self
cannot be called through a trait object because the concrete type Self
is unknown at runtime. Similarly, generic methods would require different vtable entries for each potential type substitution, which is not supported by the trait object mechanism.
Many common traits like std::fmt::Debug
, std::fmt::Display
, and custom traits defining behavior are object-safe. A notable example of a non-object-safe trait is Clone
, because its clone
method returns Self
. If you need to clone trait objects, you typically define a separate clone_box
method within the trait that returns Box<dyn YourTrait>
.