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 to std::unique_ptr<Base> in C++).
  • Other pointer types like Rc<dyn Trait> or Arc<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:

  1. A pointer to the instance’s data (e.g., the memory holding the Dog or Cat struct).
  2. 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 of Speaker).

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:

  1. Follows the vtable pointer in the fat pointer to find the vtable.
  2. Looks up the appropriate function pointer for the speak method within that vtable.
  3. 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:

  1. Receiver Type: All methods must have a receiver (self, &self, or &mut self) as their first parameter, or be explicitly callable without requiring Self (e.g., using where Self: Sized).
  2. No Self Return Type: Methods cannot return the concrete type Self.
  3. 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>.