076 — Trait Objects (Dynamic Dispatch)
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
&dyn Trait and Box<dyn Trait> for dynamic dispatchdyn Trait (runtime polymorphism) vs generics <T: Trait> (compile-time monomorphization)dyn Trait cannot be used with non-object-safe traitsCode 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).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.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);
}
}#[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
Rust Approach
dyn Trait behind a pointer (Box<dyn Trait>, &dyn Trait)Self in return positionComparison Table
| Feature | OCaml | Rust |
|---|---|---|
| Dynamic dispatch | Objects / first-class modules | dyn Trait |
| Pointer type | Implicit (GC) | Box<dyn T> / &dyn T |
| Type erasure | Yes | Yes (via vtable) |
| Overhead | Method lookup | Fat pointer + vtable |
Exercises
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.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.area via &dyn Shape (dynamic dispatch) vs fn area<T: Shape>(s: &T) (static dispatch) on 10M calls. Quantify the vtable overhead.