400: Static vs. Dynamic Trait Dispatch
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
total_area_static and total_area_dynamic express the same logic with different dispatchCode 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
T: Shape vs. dyn Shape); OCaml makes this implicit based on whether objects or modules are used.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);
}
}#[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
Shape { Circle(Circle), Square(Square) } and fn total_area_enum(shapes: &[Shape]) -> f64. Benchmark all three approaches (static, dynamic, enum) for 1 million shapes.fn area() returning a constant) 100 million times via static dispatch vs. vtable dispatch. Measure the overhead of the indirect call.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.