Using Trait Objects for Dynamic Polymorphism in Rust

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:

  1. A pointer to the actual data (the instance of the struct).
  2. 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:

  1. The trait does not require Self: Sized.
  2. All of its methods are object-safe.

A method is object-safe if:

  1. It does not have any generic type parameters.
  2. Its return type is not Self.
  3. The first parameter is not self by 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.