๐Ÿฆ€ Functional Rust

386: Object-Safe Traits

Difficulty: 3 Level: Advanced Only object-safe traits can be used as `dyn Trait` โ€” the rules determine which ones qualify.

The Problem This Solves

Sometimes you don't know the concrete type at compile time. You want a `Vec<Box<dyn Shape>>` that holds circles, rectangles, and triangles together โ€” heterogeneous collections with runtime dispatch. For this, Rust needs to build a vtable: a table of function pointers, one per trait method. The vtable is attached to a fat pointer (`dyn Trait = data ptr + vtable ptr`). Not every trait can have a vtable. If a method returns `Self` (the concrete type), the vtable can't know what size to allocate โ€” different types have different sizes. If a method is generic over `T`, there's no single function pointer to store; every `T` produces a different monomorphized version. These traits are not object-safe and cannot be used as `dyn Trait`. Understanding the rules lets you design traits that are usable both statically (generics) and dynamically (trait objects), choosing the right tool for plugin systems, callbacks, and heterogeneous collections.

The Intuition

Object safety is about whether the compiler can build a vtable for your trait. A vtable is a fixed-size array of function pointers. That means each method must have exactly one version and must work through a pointer without knowing the concrete type. The two main violations: `-> Self` (can't know the return type size) and `fn method<T>` (would need infinite vtable entries, one per `T`). The workaround for `-> Self` is to return a `Box<dyn Trait>` instead, or gate the method with `where Self: Sized` to exclude it from the vtable.

How It Works in Rust

// Object-SAFE: no Self returns, no generic methods
trait Drawable {
 fn draw(&self);
 fn area(&self) -> f64;

 // This method is excluded from the vtable via where Self: Sized
 // It's available on concrete types but not through dyn Drawable
 fn clone_box(&self) -> Box<dyn Drawable> where Self: Sized + Clone {
     Box::new(self.clone())
 }
}

struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }

impl Drawable for Circle {
 fn draw(&self) { println!("Circle(r={})", self.radius); }
 fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

impl Drawable for Rectangle {
 fn draw(&self) { println!("Rectangle({}x{})", self.width, self.height); }
 fn area(&self) -> f64 { self.width * self.height }
}

// Heterogeneous collection โ€” works because Drawable is object-safe
fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 {
 shapes.iter().map(|s| s.area()).sum()
}

fn main() {
 let shapes: Vec<Box<dyn Drawable>> = vec![
     Box::new(Circle { radius: 5.0 }),
     Box::new(Rectangle { width: 4.0, height: 6.0 }),
 ];
 println!("Total area: {:.2}", total_area(&shapes));
}
What makes a trait NOT object-safe:
trait NotObjectSafe {
 fn clone_self(&self) -> Self;       // returns Self โ€” size unknown
 fn map<T>(&self, f: fn(f64) -> T); // generic method โ€” infinite vtable entries
}
// let _: &dyn NotObjectSafe; // COMPILE ERROR

What This Unlocks

Key Differences

ConceptOCamlRust
Heterogeneous dispatchClass types (`drawable list`) โ€” runtime via object method table`Vec<Box<dyn Drawable>>` โ€” fat pointer with vtable
Object safety rulesImplicit โ€” OCaml OO always dispatches dynamicallyExplicit โ€” trait must satisfy rules; compiler rejects violations
Opt-out for unsafe methodsNo direct mechanism`where Self: Sized` excludes a method from the vtable
CostHeap allocation + vtable (same)Heap allocation + vtable (explicit via `Box`)
// Object safety rules in Rust
use std::fmt;

// Object-SAFE trait: no Self returns, no generics
trait Drawable {
    fn draw(&self);
    fn area(&self) -> f64;
    // This method is excluded from vtable with where Self: Sized
    fn clone_box(&self) -> Box<dyn Drawable> where Self: Sized + Clone {
        Box::new(self.clone())
    }
}

// NOT object safe if it had:
// fn clone_self(&self) -> Self;  // returns Self
// fn map<T, F: Fn(f64) -> T>(&self, f: F) -> T;  // generic method

#[derive(Clone)]
struct Circle { radius: f64 }
#[derive(Clone)]
struct Rectangle { width: f64, height: f64 }

impl Drawable for Circle {
    fn draw(&self) { println!("Circle(r={})", self.radius); }
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}

impl Drawable for Rectangle {
    fn draw(&self) { println!("Rectangle({}x{})", self.width, self.height); }
    fn area(&self) -> f64 { self.width * self.height }
}

impl fmt::Display for Circle {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Circle({})", self.radius) }
}

fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let shapes: Vec<Box<dyn Drawable>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Rectangle { width: 4.0, height: 6.0 }),
        Box::new(Circle { radius: 2.0 }),
    ];

    for s in &shapes {
        s.draw();
        println!("  area = {:.2}", s.area());
    }
    println!("Total area: {:.2}", total_area(&shapes));

    // Demonstrating object safety check at compile time
    // The next line would fail to compile:
    // let _: &dyn fmt::Display = &Circle { radius: 1.0 }; // Display IS object safe
    println!("Display is object safe: {:?}",
        format!("{}", Circle { radius: 3.0 }));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_area() {
        let c = Circle { radius: 1.0 };
        assert!((c.area() - std::f64::consts::PI).abs() < 1e-9);
        let r = Rectangle { width: 3.0, height: 4.0 };
        assert_eq!(r.area(), 12.0);
    }

    #[test]
    fn test_dyn_dispatch() {
        let shapes: Vec<Box<dyn Drawable>> = vec![
            Box::new(Rectangle { width: 2.0, height: 3.0 }),
        ];
        assert_eq!(total_area(&shapes), 6.0);
    }
}
(* Object safety concepts in OCaml using class types *)

(* Object-safe equivalent: method returns unit, no generics *)
class type drawable = object
  method draw : unit
  method area : float
end

class circle r = object
  method draw = Printf.printf "Drawing circle with radius %.1f\n" r
  method area = Float.pi *. r *. r
end

class rectangle w h = object
  method draw = Printf.printf "Drawing %g x %g rectangle\n" w h
  method area = w *. h
end

let total_area (shapes : drawable list) =
  List.fold_left (fun acc s -> acc +. s#area) 0.0 shapes

let () =
  let shapes : drawable list = [
    new circle 5.0;
    new rectangle 4.0 6.0;
    new circle 2.0;
  ] in
  List.iter (fun s -> s#draw) shapes;
  Printf.printf "Total area: %.2f\n" (total_area shapes)