ExamplesBy LevelBy TopicLearning Paths
400 Intermediate

400: Static vs. Dynamic Trait Dispatch

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "400: Static vs. Dynamic Trait Dispatch" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Polymorphism has two implementation strategies with different performance characteristics. Key difference from OCaml: 1. **Explicit choice**: Rust makes static/dynamic dispatch an explicit API choice (`T: Shape` vs. `dyn Shape`); OCaml makes this implicit based on whether objects or modules are used.

Tutorial

The Problem

Polymorphism has two implementation strategies with different performance characteristics. Static dispatch (monomorphization) generates a separate copy of the function for each concrete type at compile time — maximum performance, larger binary. Dynamic dispatch (vtable) uses a single function that calls through a pointer table at runtime — smaller binary, supports heterogeneous collections, with the cost of one pointer indirection per virtual call. Rust gives you explicit control over this choice: impl Trait / generics for static, dyn Trait for dynamic.

Understanding this trade-off is essential for writing correct and performant Rust — it comes up in every API design decision involving traits, closures, and async code.

🎯 Learning Outcomes

  • • Understand monomorphization: the compiler generates specialized code per type
  • • Learn how vtables enable runtime polymorphism via fat pointers
  • • See the concrete performance difference: vtable call = pointer dereference + indirect jump
  • • Understand when to prefer static dispatch (tight loops, known types) vs. dynamic (plugin systems, heterogeneous collections)
  • • Learn how total_area_static and total_area_dynamic express the same logic with different dispatch
  • Code Example

    #![allow(clippy::all)]
    //! Static vs Dynamic Trait Dispatch
    
    pub trait Shape {
        fn area(&self) -> f64;
    }
    
    pub struct Circle {
        pub r: f64,
    }
    pub struct Square {
        pub side: f64,
    }
    
    impl Shape for Circle {
        fn area(&self) -> f64 {
            std::f64::consts::PI * self.r * self.r
        }
    }
    impl Shape for Square {
        fn area(&self) -> f64 {
            self.side * self.side
        }
    }
    
    // Static dispatch (monomorphization) - compile-time
    pub fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Dynamic dispatch (vtable) - runtime
    pub fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Trade-offs:
    // Static: faster (no vtable), larger binary (code duplication)
    // Dynamic: smaller binary, slower (indirection), heterogeneous collections
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_static_circles() {
            let circles = vec![Circle { r: 1.0 }, Circle { r: 2.0 }];
            let area = total_area_static(&circles);
            assert!((area - 5.0 * std::f64::consts::PI).abs() < 0.001);
        }
        #[test]
        fn test_dynamic_mixed() {
            let shapes: Vec<Box<dyn Shape>> =
                vec![Box::new(Circle { r: 1.0 }), Box::new(Square { side: 2.0 })];
            let area = total_area_dynamic(&shapes);
            assert!((area - (std::f64::consts::PI + 4.0)).abs() < 0.001);
        }
        #[test]
        fn test_square_area() {
            let s = Square { side: 3.0 };
            assert_eq!(s.area(), 9.0);
        }
        #[test]
        fn test_circle_area() {
            let c = Circle { r: 1.0 };
            assert!((c.area() - std::f64::consts::PI).abs() < 0.001);
        }
    }

    Key Differences

  • Explicit choice: Rust makes static/dynamic dispatch an explicit API choice (T: Shape vs. dyn Shape); OCaml makes this implicit based on whether objects or modules are used.
  • Binary size: Rust's monomorphization can significantly inflate binary size for generic code; OCaml's uniform representation keeps binaries smaller.
  • Heterogeneous collections: Dynamic dispatch is required for mixed-type slices in Rust; OCaml can mix objects of the same class hierarchy without explicit trait objects.
  • Inlining: Static dispatch enables inlining across trait method calls; dynamic dispatch cannot be inlined since the target is unknown at compile time.
  • OCaml Approach

    OCaml uses uniform representation for most values and dynamically dispatches object methods always. Native code OCaml performs some inlining and specialization for known types, but the programmer rarely controls the static/dynamic dispatch boundary explicitly. OCaml's first-class modules can achieve static dispatch via functor monomorphization when performance requires it.

    Full Source

    #![allow(clippy::all)]
    //! Static vs Dynamic Trait Dispatch
    
    pub trait Shape {
        fn area(&self) -> f64;
    }
    
    pub struct Circle {
        pub r: f64,
    }
    pub struct Square {
        pub side: f64,
    }
    
    impl Shape for Circle {
        fn area(&self) -> f64 {
            std::f64::consts::PI * self.r * self.r
        }
    }
    impl Shape for Square {
        fn area(&self) -> f64 {
            self.side * self.side
        }
    }
    
    // Static dispatch (monomorphization) - compile-time
    pub fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Dynamic dispatch (vtable) - runtime
    pub fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
        shapes.iter().map(|s| s.area()).sum()
    }
    
    // Trade-offs:
    // Static: faster (no vtable), larger binary (code duplication)
    // Dynamic: smaller binary, slower (indirection), heterogeneous collections
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_static_circles() {
            let circles = vec![Circle { r: 1.0 }, Circle { r: 2.0 }];
            let area = total_area_static(&circles);
            assert!((area - 5.0 * std::f64::consts::PI).abs() < 0.001);
        }
        #[test]
        fn test_dynamic_mixed() {
            let shapes: Vec<Box<dyn Shape>> =
                vec![Box::new(Circle { r: 1.0 }), Box::new(Square { side: 2.0 })];
            let area = total_area_dynamic(&shapes);
            assert!((area - (std::f64::consts::PI + 4.0)).abs() < 0.001);
        }
        #[test]
        fn test_square_area() {
            let s = Square { side: 3.0 };
            assert_eq!(s.area(), 9.0);
        }
        #[test]
        fn test_circle_area() {
            let c = Circle { r: 1.0 };
            assert!((c.area() - std::f64::consts::PI).abs() < 0.001);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_static_circles() {
            let circles = vec![Circle { r: 1.0 }, Circle { r: 2.0 }];
            let area = total_area_static(&circles);
            assert!((area - 5.0 * std::f64::consts::PI).abs() < 0.001);
        }
        #[test]
        fn test_dynamic_mixed() {
            let shapes: Vec<Box<dyn Shape>> =
                vec![Box::new(Circle { r: 1.0 }), Box::new(Square { side: 2.0 })];
            let area = total_area_dynamic(&shapes);
            assert!((area - (std::f64::consts::PI + 4.0)).abs() < 0.001);
        }
        #[test]
        fn test_square_area() {
            let s = Square { side: 3.0 };
            assert_eq!(s.area(), 9.0);
        }
        #[test]
        fn test_circle_area() {
            let c = Circle { r: 1.0 };
            assert!((c.area() - std::f64::consts::PI).abs() < 0.001);
        }
    }

    Deep Comparison

    OCaml vs Rust: 400-trait-dispatch

    Exercises

  • Enum dispatch: Implement a third variant using an enum Shape { Circle(Circle), Square(Square) } and fn total_area_enum(shapes: &[Shape]) -> f64. Benchmark all three approaches (static, dynamic, enum) for 1 million shapes.
  • Indirect overhead measurement: Write a benchmark calling a trivial method (fn area() returning a constant) 100 million times via static dispatch vs. vtable dispatch. Measure the overhead of the indirect call.
  • Plugin system: Build a shape registry that loads Box<dyn Shape> instances by name string. Show why dynamic dispatch is required here: the concrete type is unknown until the string is parsed at runtime.
  • Open Source Repos