ExamplesBy LevelBy TopicLearning Paths
076 Intermediate

076 — Trait Objects (Dynamic Dispatch)

Functional Programming

Tutorial

The Problem

Trait objects (dyn Trait) enable runtime polymorphism in Rust — the ability to work with different types through a common interface without knowing the concrete type at compile time. They are Rust's answer to OOP inheritance and interface polymorphism: Vec<Box<dyn Shape>> can hold circles, rectangles, and triangles in one collection.

Dynamic dispatch via dyn Trait is used in plugin systems, event handlers, GUI widget trees, game entity systems, and any architecture requiring heterogeneous collections. The trade-off: dynamic dispatch has a small vtable lookup overhead but enables flexibility that static generics cannot provide.

🎯 Learning Outcomes

  • • Define traits with methods and implement them for multiple types
  • • Use &dyn Trait and Box<dyn Trait> for dynamic dispatch
  • • Understand the vtable: a pointer to the trait implementation for the concrete type
  • • Compare dyn Trait (runtime polymorphism) vs generics <T: Trait> (compile-time monomorphization)
  • • Recognize that dyn Trait cannot be used with non-object-safe traits
  • Code Example

    //! 076: Trait Objects — dynamic dispatch with `dyn Trait`.
    //!
    //! Three viewpoints on runtime polymorphism in Rust:
    //! 1. A `Shape` trait implemented by `Circle` and `Rectangle`.
    //! 2. Functions that accept `&dyn Shape` and `&[Box<dyn Shape>]` to work with
    //!    heterogeneous shapes through a vtable.
    //! 3. A factory that returns `Box<dyn Shape>` so the caller is decoupled from
    //!    the concrete constructed type.
    //!
    //! This mirrors the OCaml example, where the same polymorphism is expressed
    //! with classes (`#method`) or first-class modules.
    
    use std::f64::consts::PI;
    
    /// A shape that knows its area and a human-readable name.
    pub trait Shape {
        fn area(&self) -> f64;
        fn name(&self) -> &str;
    }
    
    /// Circle defined by its radius.
    pub struct Circle {
        pub radius: f64,
    }
    
    /// Axis-aligned rectangle defined by width and height.
    pub struct Rectangle {
        pub width: f64,
        pub height: f64,
    }
    
    impl Shape for Circle {
        fn area(&self) -> f64 {
            PI * self.radius * self.radius
        }
        fn name(&self) -> &str {
            "circle"
        }
    }
    
    impl Shape for Rectangle {
        fn area(&self) -> f64 {
            self.width * self.height
        }
        fn name(&self) -> &str {
            "rectangle"
        }
    }
    
    /// Describe any shape through a `&dyn Shape` trait object.
    pub fn describe(shape: &dyn Shape) -> String {
        format!("{} with area {:.2}", shape.name(), shape.area())
    }
    
    /// Sum the areas of a heterogeneous collection of shapes.
    pub fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    /// Factory returning `Box<dyn Shape>`: the caller only sees the trait.
    pub fn make_shape(kind: &str) -> Box<dyn Shape> {
        match kind {
            "rectangle" => Box::new(Rectangle {
                width: 3.0,
                height: 4.0,
            }),
            _ => Box::new(Circle { radius: 5.0 }),
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn circle_area_and_name() {
            let c = Circle { radius: 5.0 };
            assert!((c.area() - 78.54).abs() < 0.01);
            assert_eq!(c.name(), "circle");
        }
    
        #[test]
        fn rectangle_area_and_name() {
            let r = Rectangle {
                width: 3.0,
                height: 4.0,
            };
            assert_eq!(r.area(), 12.0);
            assert_eq!(r.name(), "rectangle");
        }
    
        #[test]
        fn describe_uses_dynamic_dispatch() {
            let c: &dyn Shape = &Circle { radius: 1.0 };
            assert_eq!(describe(c), "circle with area 3.14");
            let r: &dyn Shape = &Rectangle {
                width: 2.0,
                height: 5.0,
            };
            assert_eq!(describe(r), "rectangle with area 10.00");
        }
    
        #[test]
        fn heterogeneous_collection_sums_areas() {
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 5.0 }),
                Box::new(Rectangle {
                    width: 3.0,
                    height: 4.0,
                }),
            ];
            assert!((total_area(&shapes) - (PI * 25.0 + 12.0)).abs() < 1e-9);
        }
    
        #[test]
        fn factory_returns_requested_shape() {
            assert_eq!(make_shape("rectangle").name(), "rectangle");
            assert_eq!(make_shape("circle").name(), "circle");
            assert_eq!(make_shape("anything-else").name(), "circle");
        }
    
        #[test]
        fn empty_shape_list_has_zero_area() {
            let shapes: Vec<Box<dyn Shape>> = Vec::new();
            assert_eq!(total_area(&shapes), 0.0);
        }
    }

    Key Differences

  • **dyn Trait vs records**: Rust's vtable is automatic — define the trait, implement it, use dyn Trait. OCaml requires manually building record-of-functions vtables, or using the OO subset (#name).
  • Object safety: Rust's dyn Trait requires "object safety": no methods with Self return type, no generic methods. OCaml's record-of-functions approach has no such restriction.
  • **Box for ownership**: Box<dyn Trait> owns the object. &dyn Trait borrows it. OCaml's record-of-functions is always heap-allocated (via GC) — no explicit boxing.
  • Monomorphization vs vtable: fn area<T: Shape>(s: &T) monomorphizes (separate code per type, fast). fn area(s: &dyn Shape) uses vtable (one code path, flexible). OCaml's records are always vtable-style.
  • OCaml Approach

    OCaml uses record-of-functions as its idiomatic "dynamic dispatch" (manually built vtable):

    type shape = {
      area : unit -> float;
      name : unit -> string;
    }
    
    let circle r = {
      area = (fun () -> Float.pi *. r *. r);
      name = (fun () -> "circle");
    }
    
    let rectangle w h = {
      area = (fun () -> w *. h);
      name = (fun () -> "rectangle");
    }
    
    let describe s = Printf.printf "%s: %.2f\n" (s.name ()) (s.area ())
    

    OCaml's OO subset (#method) provides an alternative with structural subtyping. The record-of-functions approach mirrors Rust's vtable more directly.

    Full Source

    //! 076: Trait Objects — dynamic dispatch with `dyn Trait`.
    //!
    //! Three viewpoints on runtime polymorphism in Rust:
    //! 1. A `Shape` trait implemented by `Circle` and `Rectangle`.
    //! 2. Functions that accept `&dyn Shape` and `&[Box<dyn Shape>]` to work with
    //!    heterogeneous shapes through a vtable.
    //! 3. A factory that returns `Box<dyn Shape>` so the caller is decoupled from
    //!    the concrete constructed type.
    //!
    //! This mirrors the OCaml example, where the same polymorphism is expressed
    //! with classes (`#method`) or first-class modules.
    
    use std::f64::consts::PI;
    
    /// A shape that knows its area and a human-readable name.
    pub trait Shape {
        fn area(&self) -> f64;
        fn name(&self) -> &str;
    }
    
    /// Circle defined by its radius.
    pub struct Circle {
        pub radius: f64,
    }
    
    /// Axis-aligned rectangle defined by width and height.
    pub struct Rectangle {
        pub width: f64,
        pub height: f64,
    }
    
    impl Shape for Circle {
        fn area(&self) -> f64 {
            PI * self.radius * self.radius
        }
        fn name(&self) -> &str {
            "circle"
        }
    }
    
    impl Shape for Rectangle {
        fn area(&self) -> f64 {
            self.width * self.height
        }
        fn name(&self) -> &str {
            "rectangle"
        }
    }
    
    /// Describe any shape through a `&dyn Shape` trait object.
    pub fn describe(shape: &dyn Shape) -> String {
        format!("{} with area {:.2}", shape.name(), shape.area())
    }
    
    /// Sum the areas of a heterogeneous collection of shapes.
    pub fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    /// Factory returning `Box<dyn Shape>`: the caller only sees the trait.
    pub fn make_shape(kind: &str) -> Box<dyn Shape> {
        match kind {
            "rectangle" => Box::new(Rectangle {
                width: 3.0,
                height: 4.0,
            }),
            _ => Box::new(Circle { radius: 5.0 }),
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn circle_area_and_name() {
            let c = Circle { radius: 5.0 };
            assert!((c.area() - 78.54).abs() < 0.01);
            assert_eq!(c.name(), "circle");
        }
    
        #[test]
        fn rectangle_area_and_name() {
            let r = Rectangle {
                width: 3.0,
                height: 4.0,
            };
            assert_eq!(r.area(), 12.0);
            assert_eq!(r.name(), "rectangle");
        }
    
        #[test]
        fn describe_uses_dynamic_dispatch() {
            let c: &dyn Shape = &Circle { radius: 1.0 };
            assert_eq!(describe(c), "circle with area 3.14");
            let r: &dyn Shape = &Rectangle {
                width: 2.0,
                height: 5.0,
            };
            assert_eq!(describe(r), "rectangle with area 10.00");
        }
    
        #[test]
        fn heterogeneous_collection_sums_areas() {
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 5.0 }),
                Box::new(Rectangle {
                    width: 3.0,
                    height: 4.0,
                }),
            ];
            assert!((total_area(&shapes) - (PI * 25.0 + 12.0)).abs() < 1e-9);
        }
    
        #[test]
        fn factory_returns_requested_shape() {
            assert_eq!(make_shape("rectangle").name(), "rectangle");
            assert_eq!(make_shape("circle").name(), "circle");
            assert_eq!(make_shape("anything-else").name(), "circle");
        }
    
        #[test]
        fn empty_shape_list_has_zero_area() {
            let shapes: Vec<Box<dyn Shape>> = Vec::new();
            assert_eq!(total_area(&shapes), 0.0);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn circle_area_and_name() {
            let c = Circle { radius: 5.0 };
            assert!((c.area() - 78.54).abs() < 0.01);
            assert_eq!(c.name(), "circle");
        }
    
        #[test]
        fn rectangle_area_and_name() {
            let r = Rectangle {
                width: 3.0,
                height: 4.0,
            };
            assert_eq!(r.area(), 12.0);
            assert_eq!(r.name(), "rectangle");
        }
    
        #[test]
        fn describe_uses_dynamic_dispatch() {
            let c: &dyn Shape = &Circle { radius: 1.0 };
            assert_eq!(describe(c), "circle with area 3.14");
            let r: &dyn Shape = &Rectangle {
                width: 2.0,
                height: 5.0,
            };
            assert_eq!(describe(r), "rectangle with area 10.00");
        }
    
        #[test]
        fn heterogeneous_collection_sums_areas() {
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 5.0 }),
                Box::new(Rectangle {
                    width: 3.0,
                    height: 4.0,
                }),
            ];
            assert!((total_area(&shapes) - (PI * 25.0 + 12.0)).abs() < 1e-9);
        }
    
        #[test]
        fn factory_returns_requested_shape() {
            assert_eq!(make_shape("rectangle").name(), "rectangle");
            assert_eq!(make_shape("circle").name(), "circle");
            assert_eq!(make_shape("anything-else").name(), "circle");
        }
    
        #[test]
        fn empty_shape_list_has_zero_area() {
            let shapes: Vec<Box<dyn Shape>> = Vec::new();
            assert_eq!(total_area(&shapes), 0.0);
        }
    }

    Deep Comparison

    Core Insight

    Trait objects (dyn Trait) enable runtime polymorphism. The compiler generates a vtable for method dispatch. This is Rust's equivalent of OCaml's first-class modules or object system.

    OCaml Approach

  • • Object system with structural subtyping
  • • First-class modules for ad-hoc polymorphism
  • • No explicit vtable — runtime dispatch via method lookup
  • Rust Approach

  • dyn Trait behind a pointer (Box<dyn Trait>, &dyn Trait)
  • • Vtable-based dispatch (two-pointer fat pointer)
  • • Object safety rules: no generics, no Self in return position
  • Comparison Table

    FeatureOCamlRust
    Dynamic dispatchObjects / first-class modulesdyn Trait
    Pointer typeImplicit (GC)Box<dyn T> / &dyn T
    Type erasureYesYes (via vtable)
    OverheadMethod lookupFat pointer + vtable

    Exercises

  • Plugin system: Define a Plugin trait with name(&self) -> &str and execute(&self, input: &str) -> String. Build a PluginRegistry that stores Vec<Box<dyn Plugin>> and dispatches by name.
  • Object safety: Attempt to use Clone as a trait object: &dyn Clone. Observe the compiler error. Explain why Clone is not object-safe and how to work around it with a CloneBoxed trait.
  • Benchmark: Measure the performance difference between calling area via &dyn Shape (dynamic dispatch) vs fn area<T: Shape>(s: &T) (static dispatch) on 10M calls. Quantify the vtable overhead.
  • Open Source Repos