ExamplesBy LevelBy TopicLearning Paths
124 Intermediate

dyn Trait — Dynamic Dispatch

Functional Programming

Tutorial

The Problem

Sometimes the concrete type implementing a trait is unknown at compile time — a plugin system, a heterogeneous collection of shapes, or a UI widget tree. Static dispatch (impl Trait / generics) requires knowing the type at compile time and produces one copy of the code per type. Dynamic dispatch (dyn Trait) uses a vtable to resolve method calls at runtime, enabling heterogeneous collections and open extension without recompilation. Understanding when to choose each is a core Rust design skill.

🎯 Learning Outcomes

  • • Understand the three polymorphism strategies: dyn Trait, impl Trait/generics, and enum dispatch
  • • Learn what a vtable is and why dyn Trait costs one pointer indirection per method call
  • • See when each strategy is appropriate: open extension vs. closed set vs. performance-critical paths
  • • Recognize the dyn-compatibility rules that restrict which traits can be used with dyn
  • Code Example

    pub trait Shape {
        fn area(&self) -> f64;
        fn name(&self) -> &str;
    }
    
    pub fn total_area_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Heterogeneous Vec — the canonical use case
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Rect { width: 3.0, height: 4.0 }),
    ];

    Key Differences

  • Cost: Rust dyn Trait adds one indirection per call plus heap allocation; OCaml objects similarly add an indirection; OCaml variants use direct dispatch via match.
  • Openness: dyn Trait allows external crates to add new types; enum dispatch is closed; OCaml polymorphic variants allow open extension similarly to dyn Trait.
  • Trait object restrictions: Rust requires traits to be "dyn-compatible" (no generic methods, no Self in non-receiver position); OCaml objects have no such restriction.
  • Fat pointers: Rust's dyn Trait reference is two machine words (data + vtable); OCaml object references are one word (the object header points to the method table).
  • OCaml Approach

    OCaml's object system provides true dynamic dispatch via virtual method tables. OCaml's idiomatic approach for sum types uses variants (type shape = Circle of float | Rect of float * float) with pattern matching — equivalent to Rust's enum dispatch, and the most common approach in functional OCaml code. First-class modules provide a form of existential dispatch similar to Box<dyn Trait>.

    Full Source

    #![allow(clippy::all)]
    //! Example 124: dyn Trait — Dynamic Dispatch
    //!
    //! Three strategies for polymorphism in Rust, shown side-by-side:
    //!
    //! 1. `dyn Trait` — fat-pointer vtable dispatch; accepts an open, heterogeneous
    //!    set of types at the cost of one pointer indirection per call and heap
    //!    allocation per value.
    //!
    //! 2. `impl Trait` / generics — monomorphized at compile time; zero overhead,
    //!    but every element in a collection must be the same concrete type.
    //!
    //! 3. Enum dispatch — exhaustive `match`; no vtable, no heap, fastest runtime,
    //!    but the set of variants is closed (you control them all).
    
    use std::f64::consts::PI;
    
    // ---------------------------------------------------------------------------
    // Shared trait
    // ---------------------------------------------------------------------------
    
    pub trait Shape {
        fn area(&self) -> f64;
        fn name(&self) -> &str;
    }
    
    // ---------------------------------------------------------------------------
    // Concrete types
    // ---------------------------------------------------------------------------
    
    pub struct Circle {
        pub radius: f64,
    }
    
    pub struct Rect {
        pub width: f64,
        pub height: f64,
    }
    
    pub struct Triangle {
        pub base: 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 Rect {
        fn area(&self) -> f64 {
            self.width * self.height
        }
        fn name(&self) -> &str {
            "rectangle"
        }
    }
    
    impl Shape for Triangle {
        fn area(&self) -> f64 {
            0.5 * self.base * self.height
        }
        fn name(&self) -> &str {
            "triangle"
        }
    }
    
    // ---------------------------------------------------------------------------
    // Approach 1: dyn Trait — dynamic dispatch via vtable
    //
    // `Box<dyn Shape>` is a fat pointer: one pointer to the heap-allocated value,
    // one pointer to the vtable. The vtable stores function pointers for each
    // trait method. This allows a Vec of mixed concrete types — the canonical
    // use case for `dyn Trait`.
    // ---------------------------------------------------------------------------
    
    pub fn total_area_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    pub fn largest_dyn(shapes: &[Box<dyn Shape>]) -> Option<&dyn Shape> {
        shapes
            .iter()
            .max_by(|a, b| {
                a.area()
                    .partial_cmp(&b.area())
                    .unwrap_or(std::cmp::Ordering::Equal)
            })
            .map(|s| s.as_ref())
    }
    
    // ---------------------------------------------------------------------------
    // Approach 2: impl Trait / generics — static dispatch (zero-cost)
    //
    // Monomorphized per concrete type at compile time: no vtable, no heap boxing.
    // All elements in a slice must share the same concrete type.
    // ---------------------------------------------------------------------------
    
    pub fn total_area_static<S: Shape>(shapes: &[S]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // ---------------------------------------------------------------------------
    // Approach 3: Enum dispatch — closed set, no allocation, exhaustive matching
    //
    // The compiler sees every variant at compile time. No fat pointers, no heap.
    // The compiler can inline match arms and avoid any indirection. The trade-off:
    // you must own every variant; third-party types cannot be added.
    // ---------------------------------------------------------------------------
    
    pub enum AnyShape {
        Circle(Circle),
        Rect(Rect),
        Triangle(Triangle),
    }
    
    impl AnyShape {
        pub fn area(&self) -> f64 {
            match self {
                AnyShape::Circle(c) => c.area(),
                AnyShape::Rect(r) => r.area(),
                AnyShape::Triangle(t) => t.area(),
            }
        }
    
        pub fn name(&self) -> &str {
            match self {
                AnyShape::Circle(c) => c.name(),
                AnyShape::Rect(r) => r.name(),
                AnyShape::Triangle(t) => t.name(),
            }
        }
    }
    
    pub fn total_area_enum(shapes: &[AnyShape]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn approx_eq(a: f64, b: f64) -> bool {
            (a - b).abs() < 1e-9
        }
    
        #[test]
        fn test_individual_shape_areas() {
            assert!(approx_eq(Circle { radius: 1.0 }.area(), PI));
            assert!(approx_eq(
                Rect {
                    width: 3.0,
                    height: 4.0
                }
                .area(),
                12.0
            ));
            assert!(approx_eq(
                Triangle {
                    base: 6.0,
                    height: 4.0
                }
                .area(),
                12.0
            ));
        }
    
        #[test]
        fn test_dyn_heterogeneous_collection() {
            // This is the defining use case for dyn Trait: mixed concrete types in one Vec.
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 1.0 }),
                Box::new(Rect {
                    width: 2.0,
                    height: 3.0,
                }),
                Box::new(Triangle {
                    base: 4.0,
                    height: 2.0,
                }),
            ];
            // circle: PI, rect: 6.0, triangle: 4.0
            assert!(approx_eq(total_area_dyn(&shapes), PI + 6.0 + 4.0));
        }
    
        #[test]
        fn test_dyn_empty_collection() {
            let shapes: Vec<Box<dyn Shape>> = vec![];
            assert!(approx_eq(total_area_dyn(&shapes), 0.0));
        }
    
        #[test]
        fn test_largest_dyn_picks_correct_shape() {
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 1.0 }),
                Box::new(Rect {
                    width: 10.0,
                    height: 10.0,
                }), // area 100 — largest
                Box::new(Triangle {
                    base: 2.0,
                    height: 2.0,
                }),
            ];
            assert_eq!(largest_dyn(&shapes).unwrap().name(), "rectangle");
        }
    
        #[test]
        fn test_largest_dyn_single_element() {
            let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 3.0 })];
            assert_eq!(largest_dyn(&shapes).unwrap().name(), "circle");
        }
    
        #[test]
        fn test_largest_dyn_empty_returns_none() {
            let shapes: Vec<Box<dyn Shape>> = vec![];
            assert!(largest_dyn(&shapes).is_none());
        }
    
        #[test]
        fn test_static_dispatch_homogeneous_slice() {
            // static dispatch: all elements must be the same type
            let circles = [Circle { radius: 1.0 }, Circle { radius: 2.0 }];
            // PI*1^2 + PI*2^2 = PI + 4*PI = 5*PI
            assert!(approx_eq(total_area_static(&circles), 5.0 * PI));
        }
    
        #[test]
        fn test_enum_dispatch_total_area() {
            let shapes = vec![
                AnyShape::Circle(Circle { radius: 1.0 }),
                AnyShape::Rect(Rect {
                    width: 3.0,
                    height: 4.0,
                }),
                AnyShape::Triangle(Triangle {
                    base: 6.0,
                    height: 4.0,
                }),
            ];
            assert!(approx_eq(total_area_enum(&shapes), PI + 12.0 + 12.0));
        }
    
        #[test]
        fn test_enum_dispatch_names() {
            let shapes = vec![
                AnyShape::Circle(Circle { radius: 1.0 }),
                AnyShape::Rect(Rect {
                    width: 1.0,
                    height: 1.0,
                }),
                AnyShape::Triangle(Triangle {
                    base: 1.0,
                    height: 1.0,
                }),
            ];
            let names: Vec<&str> = shapes.iter().map(|s| s.name()).collect();
            assert_eq!(names, vec!["circle", "rectangle", "triangle"]);
        }
    
        #[test]
        fn test_all_three_strategies_agree() {
            // dyn Trait, static dispatch, and enum must all produce the same total
            // for the same logical shapes.
            let dyn_shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 2.0 }),
                Box::new(Rect {
                    width: 3.0,
                    height: 5.0,
                }),
            ];
            let dyn_total = total_area_dyn(&dyn_shapes);
    
            let static_circles = [Circle { radius: 2.0 }];
            let static_rects = [Rect {
                width: 3.0,
                height: 5.0,
            }];
            let static_total = total_area_static(&static_circles) + total_area_static(&static_rects);
    
            let enum_shapes = vec![
                AnyShape::Circle(Circle { radius: 2.0 }),
                AnyShape::Rect(Rect {
                    width: 3.0,
                    height: 5.0,
                }),
            ];
            let enum_total = total_area_enum(&enum_shapes);
    
            assert!(approx_eq(dyn_total, static_total));
            assert!(approx_eq(dyn_total, enum_total));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn approx_eq(a: f64, b: f64) -> bool {
            (a - b).abs() < 1e-9
        }
    
        #[test]
        fn test_individual_shape_areas() {
            assert!(approx_eq(Circle { radius: 1.0 }.area(), PI));
            assert!(approx_eq(
                Rect {
                    width: 3.0,
                    height: 4.0
                }
                .area(),
                12.0
            ));
            assert!(approx_eq(
                Triangle {
                    base: 6.0,
                    height: 4.0
                }
                .area(),
                12.0
            ));
        }
    
        #[test]
        fn test_dyn_heterogeneous_collection() {
            // This is the defining use case for dyn Trait: mixed concrete types in one Vec.
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 1.0 }),
                Box::new(Rect {
                    width: 2.0,
                    height: 3.0,
                }),
                Box::new(Triangle {
                    base: 4.0,
                    height: 2.0,
                }),
            ];
            // circle: PI, rect: 6.0, triangle: 4.0
            assert!(approx_eq(total_area_dyn(&shapes), PI + 6.0 + 4.0));
        }
    
        #[test]
        fn test_dyn_empty_collection() {
            let shapes: Vec<Box<dyn Shape>> = vec![];
            assert!(approx_eq(total_area_dyn(&shapes), 0.0));
        }
    
        #[test]
        fn test_largest_dyn_picks_correct_shape() {
            let shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 1.0 }),
                Box::new(Rect {
                    width: 10.0,
                    height: 10.0,
                }), // area 100 — largest
                Box::new(Triangle {
                    base: 2.0,
                    height: 2.0,
                }),
            ];
            assert_eq!(largest_dyn(&shapes).unwrap().name(), "rectangle");
        }
    
        #[test]
        fn test_largest_dyn_single_element() {
            let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 3.0 })];
            assert_eq!(largest_dyn(&shapes).unwrap().name(), "circle");
        }
    
        #[test]
        fn test_largest_dyn_empty_returns_none() {
            let shapes: Vec<Box<dyn Shape>> = vec![];
            assert!(largest_dyn(&shapes).is_none());
        }
    
        #[test]
        fn test_static_dispatch_homogeneous_slice() {
            // static dispatch: all elements must be the same type
            let circles = [Circle { radius: 1.0 }, Circle { radius: 2.0 }];
            // PI*1^2 + PI*2^2 = PI + 4*PI = 5*PI
            assert!(approx_eq(total_area_static(&circles), 5.0 * PI));
        }
    
        #[test]
        fn test_enum_dispatch_total_area() {
            let shapes = vec![
                AnyShape::Circle(Circle { radius: 1.0 }),
                AnyShape::Rect(Rect {
                    width: 3.0,
                    height: 4.0,
                }),
                AnyShape::Triangle(Triangle {
                    base: 6.0,
                    height: 4.0,
                }),
            ];
            assert!(approx_eq(total_area_enum(&shapes), PI + 12.0 + 12.0));
        }
    
        #[test]
        fn test_enum_dispatch_names() {
            let shapes = vec![
                AnyShape::Circle(Circle { radius: 1.0 }),
                AnyShape::Rect(Rect {
                    width: 1.0,
                    height: 1.0,
                }),
                AnyShape::Triangle(Triangle {
                    base: 1.0,
                    height: 1.0,
                }),
            ];
            let names: Vec<&str> = shapes.iter().map(|s| s.name()).collect();
            assert_eq!(names, vec!["circle", "rectangle", "triangle"]);
        }
    
        #[test]
        fn test_all_three_strategies_agree() {
            // dyn Trait, static dispatch, and enum must all produce the same total
            // for the same logical shapes.
            let dyn_shapes: Vec<Box<dyn Shape>> = vec![
                Box::new(Circle { radius: 2.0 }),
                Box::new(Rect {
                    width: 3.0,
                    height: 5.0,
                }),
            ];
            let dyn_total = total_area_dyn(&dyn_shapes);
    
            let static_circles = [Circle { radius: 2.0 }];
            let static_rects = [Rect {
                width: 3.0,
                height: 5.0,
            }];
            let static_total = total_area_static(&static_circles) + total_area_static(&static_rects);
    
            let enum_shapes = vec![
                AnyShape::Circle(Circle { radius: 2.0 }),
                AnyShape::Rect(Rect {
                    width: 3.0,
                    height: 5.0,
                }),
            ];
            let enum_total = total_area_enum(&enum_shapes);
    
            assert!(approx_eq(dyn_total, static_total));
            assert!(approx_eq(dyn_total, enum_total));
        }
    }

    Deep Comparison

    OCaml vs Rust: dyn Trait — Dynamic Dispatch

    Side-by-Side Code

    OCaml — First-class modules as open polymorphism

    module type Shape = sig
      val area : unit -> float
      val name : unit -> string
    end
    
    let total_area (shapes : (module Shape) list) =
      List.fold_left (fun acc (module S : Shape) -> acc +. S.area ()) 0.0 shapes
    
    let circle r : (module Shape) = (module struct
      let area () = Float.pi *. r *. r
      let name () = "circle"
    end)
    
    let rect w h : (module Shape) = (module struct
      let area () = w *. h
      let name () = "rectangle"
    end)
    

    Rust — dyn Trait (dynamic dispatch, open set)

    pub trait Shape {
        fn area(&self) -> f64;
        fn name(&self) -> &str;
    }
    
    pub fn total_area_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Heterogeneous Vec — the canonical use case
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Rect { width: 3.0, height: 4.0 }),
    ];
    

    Rust — Enum dispatch (closed set, no heap)

    pub enum AnyShape {
        Circle(Circle),
        Rect(Rect),
        Triangle(Triangle),
    }
    
    impl AnyShape {
        pub fn area(&self) -> f64 {
            match self {
                AnyShape::Circle(c) => c.area(),
                AnyShape::Rect(r) => r.area(),
                AnyShape::Triangle(t) => t.area(),
            }
        }
    }
    
    pub fn total_area_enum(shapes: &[AnyShape]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    

    Rust — impl Trait / generics (static dispatch, zero-cost, homogeneous)

    pub fn total_area_static<S: Shape>(shapes: &[S]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    

    Type Signatures

    ConceptOCamlRust
    Trait/module typemodule type Shapetrait Shape
    Dynamic polymorphism(module Shape) listVec<Box<dyn Shape>>
    Fat pointeranonymous (first-class module)Box<dyn Shape> = (data ptr, vtable ptr)
    Static polymorphism'a list with concrete module&[S] where S: Shape
    Closed-set dispatchN/A (use ADT variants)enum AnyShape { Circle(..), Rect(..) }

    Key Insights

  • **OCaml first-class modules ≈ Rust dyn Trait**: Both let you package a value with its method table at runtime and store mixed types in a list/vec. OCaml's mechanism is more structural; Rust's is more explicit about heap allocation and pointer indirection.
  • **Box<dyn Shape> is a fat pointer**: It carries two machine words — one to the heap data, one to a vtable of function pointers. Every method call goes through the vtable at runtime. The cost is real but usually negligible compared to what the methods do.
  • **Enum dispatch beats dyn Trait for closed sets**: When you own every variant, an enum + match lets the compiler inline everything, skip heap allocation, and enforce exhaustiveness. dyn Trait wins when the set of types is open (plugins, user-provided types, type-erased collections built from separate crates).
  • **impl Trait / generics win when types are homogeneous**: Monomorphization produces one specialized copy of the function per concrete type — zero overhead, no vtable — but every element of a slice must be the same type. You cannot mix Circle and Rect in a &[impl Shape].
  • Object safety: Not every trait can be used with dyn. A trait is object-safe only if its methods take &self / &mut self (no Self in return position, no generic methods). This is enforced at compile time in Rust; OCaml module types have no such restriction.
  • When to Use Each Style

    **Use dyn Trait when:** you need a heterogeneous collection, you're exposing a plugin API, or the concrete types arrive from external crates you don't control.

    Use enum dispatch when: the set of variants is fixed and you own all of them — you get exhaustiveness checking, no heap allocation, and faster dispatch.

    **Use impl Trait / generics when:** all elements in a collection are the same concrete type and you want zero-overhead monomorphized code without boxing.

    Exercises

  • Add a Triangle variant to the enum-dispatch version and verify it compiles without touching the dyn Trait version.
  • Write a largest_area(shapes: &[Box<dyn Shape>]) -> f64 function using the dynamic dispatch version.
  • Benchmark the three approaches (dyn, generic, enum) with 10,000 shape evaluations and compare throughput.
  • Open Source Repos