Skip to content
rusthacks.dev
Generics

Trait Objects in Rust and Why Some Traits Cannot Be One

A trait object is a pointer plus a vtable, which lets one Vec hold many types. See how to build one, what the cost is, and why some traits cannot be used this way.

7 min read
Summarize in AI
Flat illustration for the post “Trait Objects in Rust and Why Some Traits Cannot Be One”, composed around memory.

A Vec<Circle> holds circles. A Vec<Box<dyn Shape>> holds anything that is a Shape, circles and rectangles side by side. The second is a trait object, and it is how Rust handles the cases where you do not know the concrete type until the program runs: a plugin list, a set of handlers registered by configuration, heterogeneous items in a scene. Trait objects work through a pair of pointers, they have a size cost you can print, and they come with a rule that rules some traits out. This post shows the syntax on a working program, prints the size of the pieces, explains what dyn actually does at run time, and shows the two errors you will see when you hold one wrong.

The code compiled and ran on Rust 1.99 in edition 2024, and the output is the program's own.

A collection of different types

src/main.rsrust
use std::mem::size_of;

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;
}

struct Circle(f64);
struct Rect(f64, f64);

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.0 * self.0
    }
    fn name(&self) -> &str {
        "circle"
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.0 * self.1
    }
    fn name(&self) -> &str {
        "rect"
    }
}

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

fn describe(shape: &dyn Shape) -> String {
    format!("{} with area {:.1}", shape.name(), shape.area())
}

fn main() {
    // Different concrete types in one collection: only possible with a trait object.
    let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(1.0)), Box::new(Rect(2.0, 3.0)), Box::new(Circle(0.5))];
    println!("total area {:.3}", total_area(&shapes));
    for s in &shapes {
        println!("  {}", describe(s.as_ref()));
    }

    println!("&Circle      = {} bytes", size_of::<&Circle>());
    println!("&dyn Shape   = {} bytes", size_of::<&dyn Shape>());
    println!("Box<dyn Shape> = {} bytes", size_of::<Box<dyn Shape>>());
    println!("Circle itself = {} bytes, Rect = {} bytes", size_of::<Circle>(), size_of::<Rect>());
}
Text
total area 9.927
  circle with area 3.1
  rect with area 6.0
  circle with area 0.8
&Circle      = 8 bytes
&dyn Shape   = 16 bytes
Box<dyn Shape> = 16 bytes
Circle itself = 8 bytes, Rect = 16 bytes

The vector holds three shapes of two types. total_area sums their areas by calling s.area() on each, and the call goes to Circle::area or Rect::area depending on what is inside. The function describe takes a &dyn Shape, a borrowed trait object, and works on any shape without being generic. Three things are worth noticing in the code. The element type is Box<dyn Shape>, a smart pointer to a value of some unknown size, because a Circle and a Rect are different sizes and a vector needs equal-sized elements. The vec! call coerces each Box<Circle> and Box<Rect> into Box<dyn Shape> at the place the type annotation names. And describe(s.as_ref()) borrows the box's contents as &dyn Shape.

What a trait object is, in bytes

The last four lines of output explain the machinery. A &Circle is 8 bytes, a single pointer. A &dyn Shape is 16 bytes, two pointers: one to the data and one to a vtable. A Box<dyn Shape> is also 16 bytes for the same reason. This is called a fat pointer.

The vtable is a table the compiler builds for each (type, trait) pair that gets used as a trait object. For Circle as dyn Shape, it holds the address of Circle's area and name methods, the size and alignment of Circle, and the function that drops it. When s.area() runs on a dyn Shape, the code reads the area address out of the vtable pointer and calls it, passing the data pointer. That is the dynamic dispatch. The cost is one extra memory read and an indirect call, which static versus dynamic dispatch measures.

The vtable is why the sizes differ from the concrete types: Circle is 8 bytes and Rect 16, and the trait-object handle is the same 16 for both, because the handle holds a pointer to the value and not the value. The value lives wherever the pointer points, in a Box on the heap, in a local variable on the stack for a &dyn, or inside another structure.

Why a collection of bare dyn does not work

The type dyn Shape has no known size, since any type might be behind it. Rust requires most values to have a size known at compile time, so you can only handle dyn Shape through a pointer: &dyn Shape, Box<dyn Shape>, Rc<dyn Shape> and so on. Trying to store it directly fails with an error about size:

src/main.rsrust
let shapes: Vec<dyn Shape> = vec![];
Text
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
  --> t166y.rs:16:17
   |
16 |     let shapes: Vec<dyn Shape> = vec![];
   |                 ^^^^^^^^^^^^^^ doesn't have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `dyn Shape`
note: required by an implicit `Sized` bound in `Vec`

Vec<T> has an implicit T: Sized bound, and dyn Shape is not Sized. The fix is the one in the working program: put a pointer around it, Vec<Box<dyn Shape>>. This is the error to remember: whenever you see "the size for values of type dyn ... cannot be known", you forgot a Box, a & or an Rc.

The other common mistake is expecting a plain vector literal to mix types. Without the trait object annotation, Rust infers the element type from the first item:

src/main.rsrust
let shapes = vec![Circle(1.0), 2.0];
Text
error[E0308]: mismatched types
  --> t166x.rs:14:36
   |
14 |     let shapes = vec![Circle(1.0), 2.0];
   |                                    ^^^ expected `Circle`, found floating-point number

The vector's type became Vec<Circle> from the first element, and the second does not fit. When you want a mixed collection, write the element type, Vec<Box<dyn Shape>>, and box each element.

Lifetimes and Send

Two annotations show up often on trait objects, and they are worth recognizing. A Box<dyn Shape> is shorthand for Box<dyn Shape + 'static>, meaning the value inside borrows nothing that could expire. If you want to store a shape that borrows local data, write the lifetime: Box<dyn Shape + 'a>. And Box<dyn Shape> is not automatically Send; to move one to another thread you write Box<dyn Shape + Send>. Frameworks that spawn tasks usually require dyn Trait + Send + Sync + 'static, and a compile error mentioning "cannot be sent between threads safely" on a trait object is pointing at that missing + Send.

Which traits can be trait objects

A trait is usable as dyn Trait only if the compiler can build a vtable for it, and that rules out traits with methods whose calls depend on the concrete type: generic methods, methods returning Self, and a few others. Such a trait is not dyn-compatible, formerly called object-safe. Clone is the standard example: fn clone(&self) -> Self returns a Self of unknown size, so Box<dyn Clone> does not compile. Dyn compatibility lists the exact rules, and E0038 shows the error and the three ways around it.

What you can do with a trait object

You can call the trait's methods. You can pass it around by reference or box. You can compare it only if you build that in, since == on dyn Shape needs PartialEq to be dyn-compatible, which it is not directly. You cannot call methods that belong only to the concrete type, such as a radius() on Circle, because the type is erased. If you need them back, add the method to the trait, or give the trait an as_any method that returns &dyn Any and downcast; Any and downcasting shows the pattern, with its costs.

When to use one

Use a trait object when the set of types is open or decided at run time, when you want to store different types together, or when you want a field or return type that can be swapped without making the surrounding code generic. Prefer generics when the type is known at the call site and you want the optimizer to see through it. Prefer an enum when the set is closed and small. The comparison of impl Trait and dyn Trait covers the argument and return positions, and Box dyn Trait covers when the allocation is worth paying for.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.