Trait objects and dyn

Rust · Advanced · 14 min

Plugin systems, heterogeneous collections and error boxes need dynamic dispatch rather than generics.

Generics pick the concrete type at compile time. A trait object, dyn Trait, picks it at runtime. Because the size of the underlying type is unknown, a trait object always sits behind a pointer: &dyn Draw, Box<dyn Draw>, Rc<dyn Draw> or Arc<dyn Draw + Send + Sync>.

Such a pointer is a fat pointer: two words, one pointing to the data and one to a vtable, a static table holding the concrete type's method pointers plus its size, alignment and drop function. Calling a method is an indirect call through the vtable. That costs a little (mostly by blocking inlining), but you get one compiled copy of the calling code and can mix types in one collection: Vec<Box<dyn Draw>>.

Not every trait can be a trait object. A trait is dyn compatible (formerly "object safe") only if its methods could be called through a vtable, which rules out:

  • generic methods (fn visit<T>(&self, t: T)), since the vtable cannot hold infinitely many instantiations,
  • methods that mention Self outside the receiver, such as returning Self,
  • associated constants, and static functions without self.

You can keep such methods on an otherwise compatible trait by adding where Self: Sized, which excludes them from the vtable. Clone is not dyn compatible, so cloning boxed objects needs a helper method like fn box_clone(&self) -> Box<dyn Shape>.

Since Rust 1.86, trait upcasting works: if trait Shape: Debug, a &dyn Shape coerces to &dyn Debug. To get back the concrete type, use std::any::Any and downcast_ref::<T>().

Rule of thumb: generics by default; dyn when you need a heterogeneous collection, want to reduce compile time or binary size, or need to choose implementations at runtime.

Example

use std::fmt::Debug;

trait Shape: Debug {
    fn area(&self) -> f64;
    fn name(&self) -> String { "shape".into() }
    fn scaled(&self, k: f64) -> Self where Self: Sized; // excluded from the vtable
}
#[derive(Debug)] struct Circle(f64);
#[derive(Debug)] struct Square(f64);
impl Shape for Circle {
    fn area(&self) -> f64 { 3.14159 * self.0 * self.0 }
    fn scaled(&self, k: f64) -> Self { Circle(self.0 * k) }
}
impl Shape for Square {
    fn area(&self) -> f64 { self.0 * self.0 }
    fn scaled(&self, k: f64) -> Self { Square(self.0 * k) }
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(1.0)), Box::new(Square(2.0).scaled(2.0))];
    println!("{:.2}", shapes.iter().map(|s| s.area()).sum::<f64>()); // 19.14
    let as_debug: &dyn Debug = shapes[1].as_ref();                   // trait upcasting (1.86+)
    println!("{as_debug:?} {}", shapes[0].name());                   // Square(4.0) shape
    println!("{}", size_of::<&dyn Shape>() == 2 * size_of::<usize>()); // true: fat pointer
}

Remember

  • dyn Trait lives behind a pointer that carries a vtable: one copy of code, indirect calls
  • Generic methods and Self-returning methods break dyn compatibility unless where Self: Sized
  • Use dyn for heterogeneous collections and runtime choice; generics otherwise

Watch out for

  • Defaulting to Box<dyn Trait> everywhere out of habit from OOP languages, where generics would be simpler and faster.

Check yourself

  1. Why can't `trait Visitor { fn visit<T: Debug>(&self, t: T); }` be used as `dyn Visitor`?
  2. What is stored in a `&dyn Shape` reference?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/reference/types/trait-object.html