Rust Trait Objects: Dynamic Dispatch with dyn

Rust offers two forms of polymorphism: compile-time polymorphism through generics and runtime polymorphism through trait objects. Generics use monomorphization—the compiler generates specialized code...

Key Insights

  • Trait objects enable runtime polymorphism in Rust through dynamic dispatch, trading compile-time optimization for flexibility when you need to store heterogeneous types in collections or return different concrete types from the same function.
  • The dyn keyword explicitly marks trait objects as fat pointers containing both a data pointer and a vtable pointer, with specific object safety rules that prevent traits with generic methods or Self return types from being used dynamically.
  • Choose trait objects over generics when you need heterogeneous collections or plugin architectures, but expect a small performance cost from vtable indirection—typically acceptable for I/O-bound operations or when code size matters more than raw speed.

Introduction to Trait Objects

Rust offers two forms of polymorphism: compile-time polymorphism through generics and runtime polymorphism through trait objects. Generics use monomorphization—the compiler generates specialized code for each concrete type you use. This delivers zero-cost abstractions but increases binary size and requires knowing all types at compile time.

Trait objects solve a different problem: when you need to work with multiple types that share behavior but can’t know all types at compile time, or when you want to store different types in the same collection.

Consider this scenario:

trait Logger {
    fn log(&self, message: &str);
}

struct FileLogger {
    path: String,
}

impl Logger for FileLogger {
    fn log(&self, message: &str) {
        println!("Writing to {}: {}", self.path, message);
    }
}

struct ConsoleLogger;

impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("{}", message);
    }
}

// This won't work - can't return different concrete types
fn get_logger(use_file: bool) -> ??? {
    if use_file {
        FileLogger { path: "app.log".to_string() }
    } else {
        ConsoleLogger
    }
}

Without trait objects, you can’t write this function. Generics won’t help because they require a single concrete type. Trait objects provide the solution.

The dyn Keyword and Syntax

The dyn keyword explicitly marks a trait as being used as a trait object. You’ll typically see it in three forms: Box<dyn Trait>, &dyn Trait, and Arc<dyn Trait>.

Here’s how to fix our logger example:

fn get_logger(use_file: bool) -> Box<dyn Logger> {
    if use_file {
        Box::new(FileLogger { path: "app.log".to_string() })
    } else {
        Box::new(ConsoleLogger)
    }
}

fn main() {
    let logger = get_logger(true);
    logger.log("Application started");
    
    // Also works with references
    let file_logger = FileLogger { path: "test.log".to_string() };
    let logger_ref: &dyn Logger = &file_logger;
    logger_ref.log("Using reference");
}

Under the hood, a trait object is a “fat pointer”—it contains two pointers:

  1. A pointer to the actual data (the concrete type instance)
  2. A pointer to a vtable (virtual method table) containing function pointers for the trait’s methods

When you call a method on a trait object, Rust looks up the correct function pointer in the vtable at runtime. This is dynamic dispatch—the method to call is determined at runtime rather than compile time.

// Conceptually, this:
let logger: Box<dyn Logger> = Box::new(FileLogger { path: "app.log".to_string() });

// Creates something like:
// [data_ptr] -> FileLogger instance
// [vtable_ptr] -> { log: FileLogger::log }

The choice between Box<dyn Trait>, &dyn Trait, and Arc<dyn Trait> depends on ownership:

  • Box<dyn Trait>: Owned trait object, heap-allocated
  • &dyn Trait: Borrowed trait object, no allocation needed
  • Arc<dyn Trait>: Shared ownership across threads

Object Safety Rules

Not every trait can be a trait object. A trait must be “object safe” to work with dyn. The compiler enforces these rules:

  1. No methods that return Self
  2. No generic methods
  3. No associated constants
  4. The trait cannot require Sized

Here’s an object-safe trait:

trait Draw {
    fn draw(&self);
    fn area(&self) -> f64;
}

And here’s a non-object-safe trait:

trait Clone {
    fn clone(&self) -> Self;  // Returns Self - not object safe!
}

trait Container {
    fn add<T>(&mut self, item: T);  // Generic method - not object safe!
}

// This won't compile:
// let drawable: Box<dyn Clone> = Box::new(some_value);

The compiler will give you clear errors:

trait Builder {
    fn build(&self) -> Self;
}

struct MyBuilder;

impl Builder for MyBuilder {
    fn build(&self) -> Self {
        MyBuilder
    }
}

fn create_builder() -> Box<dyn Builder> {
    Box::new(MyBuilder)
}

// Error: the trait `Builder` cannot be made into an object
// because method `build` references the `Self` type in its return type

Why these restrictions? The vtable needs to know exact function signatures at runtime. With Self returns or generic methods, the compiler can’t construct a valid vtable because it doesn’t know what concrete types will be used.

Practical Use Cases

Trait objects shine in scenarios where you need heterogeneous collections or runtime-determined behavior.

Heterogeneous Collections:

trait Shape {
    fn area(&self) -> f64;
    fn describe(&self) -> String;
}

struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
struct Triangle { base: f64, height: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    fn describe(&self) -> String {
        format!("Circle with radius {}", self.radius)
    }
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn describe(&self) -> String {
        format!("Rectangle {}x{}", self.width, self.height)
    }
}

impl Shape for Triangle {
    fn area(&self) -> f64 {
        0.5 * self.base * self.height
    }
    fn describe(&self) -> String {
        format!("Triangle with base {} and height {}", self.base, self.height)
    }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Rectangle { width: 4.0, height: 6.0 }),
        Box::new(Triangle { base: 3.0, height: 4.0 }),
    ];
    
    let total_area: f64 = shapes.iter().map(|s| s.area()).sum();
    println!("Total area: {}", total_area);
    
    for shape in &shapes {
        println!("{}: {:.2}", shape.describe(), shape.area());
    }
}

Plugin Architecture:

trait Plugin {
    fn name(&self) -> &str;
    fn execute(&self, input: &str) -> String;
}

struct UppercasePlugin;
struct ReversePlugin;

impl Plugin for UppercasePlugin {
    fn name(&self) -> &str { "uppercase" }
    fn execute(&self, input: &str) -> String {
        input.to_uppercase()
    }
}

impl Plugin for ReversePlugin {
    fn name(&self) -> &str { "reverse" }
    fn execute(&self, input: &str) -> String {
        input.chars().rev().collect()
    }
}

struct PluginManager {
    plugins: Vec<Box<dyn Plugin>>,
}

impl PluginManager {
    fn new() -> Self {
        PluginManager { plugins: Vec::new() }
    }
    
    fn register(&mut self, plugin: Box<dyn Plugin>) {
        self.plugins.push(plugin);
    }
    
    fn execute(&self, name: &str, input: &str) -> Option<String> {
        self.plugins
            .iter()
            .find(|p| p.name() == name)
            .map(|p| p.execute(input))
    }
}

fn main() {
    let mut manager = PluginManager::new();
    manager.register(Box::new(UppercasePlugin));
    manager.register(Box::new(ReversePlugin));
    
    if let Some(result) = manager.execute("uppercase", "hello") {
        println!("Result: {}", result);  // HELLO
    }
}

Performance Considerations

Dynamic dispatch has a cost. Each method call through a trait object requires:

  1. Dereferencing the vtable pointer
  2. Looking up the function pointer
  3. Calling through that pointer (prevents inlining)

For most applications, this overhead is negligible compared to the actual work being done. However, in tight loops or performance-critical code, it matters.

use std::time::Instant;

trait Calculator {
    fn add(&self, a: i32, b: i32) -> i32;
}

struct SimpleCalculator;

impl Calculator for SimpleCalculator {
    fn add(&self, a: i32, b: i32) -> i32 {
        a + b
    }
}

fn benchmark_static(calc: &SimpleCalculator, iterations: usize) -> u128 {
    let start = Instant::now();
    let mut sum = 0;
    for i in 0..iterations {
        sum += calc.add(i as i32, 1);
    }
    let duration = start.elapsed().as_nanos();
    println!("Static dispatch sum: {}", sum);
    duration
}

fn benchmark_dynamic(calc: &dyn Calculator, iterations: usize) -> u128 {
    let start = Instant::now();
    let mut sum = 0;
    for i in 0..iterations {
        sum += calc.add(i as i32, 1);
    }
    let duration = start.elapsed().as_nanos();
    println!("Dynamic dispatch sum: {}", sum);
    duration
}

fn main() {
    let calc = SimpleCalculator;
    let iterations = 10_000_000;
    
    let static_time = benchmark_static(&calc, iterations);
    let dynamic_time = benchmark_dynamic(&calc, iterations);
    
    println!("Static: {}ns", static_time);
    println!("Dynamic: {}ns", dynamic_time);
    println!("Overhead: {:.2}%", 
             ((dynamic_time as f64 / static_time as f64) - 1.0) * 100.0);
}

Use trait objects when:

  • You need heterogeneous collections
  • You’re building plugin systems
  • The operation is I/O-bound
  • Binary size matters more than speed

Use generics when:

  • You know all types at compile time
  • Performance is critical
  • You can accept larger binary sizes

Common Patterns and Best Practices

Error Handling with dyn Error:

The standard library uses trait objects extensively for error handling:

use std::error::Error;
use std::fs::File;
use std::io;

fn read_file(path: &str) -> Result<String, Box<dyn Error>> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    io::Read::read_to_string(&mut file, &mut contents)?;
    Ok(contents)
}

fn main() {
    match read_file("config.txt") {
        Ok(contents) => println!("{}", contents),
        Err(e) => eprintln!("Error: {}", e),
    }
}

Downcasting with Any:

Sometimes you need to recover the concrete type from a trait object:

use std::any::Any;

trait Component: Any {
    fn update(&mut self);
    fn as_any(&self) -> &dyn Any;
}

struct Player { health: i32 }
struct Enemy { damage: i32 }

impl Component for Player {
    fn update(&mut self) { self.health -= 1; }
    fn as_any(&self) -> &dyn Any { self }
}

impl Component for Enemy {
    fn update(&mut self) { self.damage += 1; }
    fn as_any(&self) -> &dyn Any { self }
}

fn main() {
    let components: Vec<Box<dyn Component>> = vec![
        Box::new(Player { health: 100 }),
        Box::new(Enemy { damage: 10 }),
    ];
    
    for component in &components {
        if let Some(player) = component.as_any().downcast_ref::<Player>() {
            println!("Player health: {}", player.health);
        }
    }
}

Trait objects are a powerful tool in Rust’s polymorphism toolkit. They sacrifice some performance for flexibility, enabling patterns that would be impossible with generics alone. Understanding when to use trait objects versus generics is key to writing idiomatic, efficient Rust code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.