Skip to content
rusthacks.dev
Generics

Box dyn Trait in Rust and When Allocation Is Worth It

Box<dyn Trait> costs one allocation and one indirect call per use. Learn what that costs, when it is worth it and the cheaper alternatives.

8 min read
Summarize in AI
Flat illustration for the post “Box dyn Trait in Rust and When Allocation Is Worth It”, composed around memory.

Box<dyn Trait> is a pointer to a heap value of unknown concrete type, plus a table of that type's methods. It is the standard way to hold "something that implements this trait" when the exact type is decided at run time, and it costs one allocation per box plus an indirect call per method. Whether that cost is worth paying depends on the job. This post goes through the five situations where a Box<dyn Trait> is the right tool, measures what it actually allocates with a counting allocator, and points at the cases where a cheaper form would do. The measurements ran on Rust 1.99, built with -O, and the output is the program's own.

What a Box<dyn Trait> is

A Box<T> owns a value of type T on the heap. When T is dyn Trait, the box also carries a pointer to a vtable. The handle is two words, 16 bytes, whatever the value inside is, and the program prints it:

src/main.rsrust
use std::alloc::{GlobalAlloc, Layout, System};
use std::mem::size_of;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCS.fetch_add(1, Relaxed);
        unsafe { System.alloc(layout) }
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        unsafe { System.dealloc(ptr, layout) }
    }
}

#[global_allocator]
static A: Counting = Counting;

fn allocations<R>(f: impl FnOnce() -> R) -> (R, usize) {
    let before = ALLOCS.load(Relaxed);
    let out = f();
    (out, ALLOCS.load(Relaxed) - before)
}

struct Event {
    name: &'static str,
}

/// An event bus whose subscribers are decided at run time.
struct Bus {
    handlers: Vec<Box<dyn Fn(&Event) -> String>>,
}

impl Bus {
    fn new() -> Self {
        Bus { handlers: Vec::new() }
    }
    fn subscribe(&mut self, handler: impl Fn(&Event) -> String + 'static) {
        self.handlers.push(Box::new(handler));
    }
    fn publish(&self, event: &Event) -> Vec<String> {
        self.handlers.iter().map(|h| h(event)).collect()
    }
}

// A recursive type needs a Box so its size is finite.
enum Expr {
    Num(f64),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
}

fn eval(e: &Expr) -> f64 {
    match e {
        Expr::Num(n) => *n,
        Expr::Add(a, b) => eval(a) + eval(b),
        Expr::Mul(a, b) => eval(a) * eval(b),
    }
}

fn main() {
    let prefix = String::from("audit");
    let mut bus = Bus::new();
    let ((), n) = allocations(|| {
        bus.subscribe(|e| format!("log: {}", e.name));
        bus.subscribe(move |e| format!("{prefix}: {}", e.name.to_uppercase()));
        bus.subscribe(|e| format!("len: {}", e.name.len()));
    });
    println!("3 boxed handlers cost {n} allocations");
    println!("{:?}", bus.publish(&Event { name: "signup" }));

    // The same callback passed by reference or generically allocates nothing.
    fn call_dyn(f: &dyn Fn(u32) -> u32, x: u32) -> u32 { f(x) }
    fn call_generic(f: impl Fn(u32) -> u32, x: u32) -> u32 { f(x) }
    let offset = 10;
    let (a, n_dyn) = allocations(|| call_dyn(&|x| x + offset, 1));
    let (b, n_gen) = allocations(|| call_generic(|x| x + offset, 1));
    println!("&dyn Fn: {a} with {n_dyn} allocations; generic: {b} with {n_gen} allocations");

    let tree = Expr::Add(
        Box::new(Expr::Num(2.0)),
        Box::new(Expr::Mul(Box::new(Expr::Num(3.0)), Box::new(Expr::Num(4.0)))),
    );
    println!("2 + 3 * 4 = {}", eval(&tree));

    println!("size_of Box<dyn Fn(u32) -> u32> = {} bytes", size_of::<Box<dyn Fn(u32) -> u32>>());
    println!("size_of Box<Expr> = {} bytes, Expr = {} bytes", size_of::<Box<Expr>>(), size_of::<Expr>());
}
Text
3 boxed handlers cost 2 allocations
["log: signup", "audit: SIGNUP", "len: 6"]
&dyn Fn: 11 with 0 allocations; generic: 11 with 0 allocations
2 + 3 * 4 = 14
size_of Box<dyn Fn(u32) -> u32> = 16 bytes
size_of Box<Expr> = 8 bytes, Expr = 24 bytes

Read the last two lines first. Box<dyn Fn(u32) -> u32> is 16 bytes: a data pointer and a vtable pointer. Box<Expr>, a box around a sized type, is 8 bytes, one pointer, because the size is known and no vtable is needed. The enum Expr itself is 24 bytes, which is what makes it possible to store it inline in a vector or inside another struct.

Situation one: a collection of different types

The first use is a collection that must hold values of different concrete types, as in the event bus above. Bus stores handlers as Vec<Box<dyn Fn(&Event) -> String>>. Each handler is a closure, every closure has its own type, and a vector holds one element type. Boxing each closure erases the difference: all three become Box<dyn Fn(&Event) -> String>. Callers subscribe with any closure, the bus calls each one, and the output shows all three responding to one event.

Situation two: callbacks registered at run time

The bus is also the second situation, a registry of callbacks that are not known when the struct is written. If Bus were generic over a single closure type, it could hold only one handler type. The box is the price of letting any code subscribe, including code that did not exist when the bus was compiled. This pattern appears in event systems, middleware chains, plugin hooks and test doubles, and in every case an allocation per handler at registration time is trivial next to the work being dispatched.

What the allocator saw

The counting allocator reports that subscribing three handlers cost two allocations, which is fewer than three, and the reason is worth knowing. The first and third closures capture nothing, so they are zero-sized types. A Box of a zero-sized value does not allocate, since there is nothing to store; the box is just a pointer to a dangling, well-aligned address. Only the second closure captures a String (prefix), so it needs 24 bytes on the heap, and the Vec that holds the handlers allocates its own buffer on the first push. Two allocations: one for the capturing closure and one for the vector. In general, Box<dyn Fn> costs an allocation only when the closure captures something.

Now compare the same closure passed by reference and generically. The program calls call_dyn(&|x| x + offset, 1) with a &dyn Fn and call_generic(|x| x + offset, 1) with an impl Fn, and both report zero allocations and the correct result, 11. A reference to a closure on the stack needs no box, and neither does a generic parameter. So the rule for callbacks is: take &dyn Fn or impl Fn when the callee uses the closure during the call and returns, and Box<dyn Fn> when it must store the closure beyond the call.

Situation three: a field you want to swap

A struct field of type Box<dyn Strategy> can hold any implementation of the trait and be replaced at run time: a logger that writes to a file or the terminal, a payment provider chosen by configuration, a clock that is real in production and fake in tests. The alternative is making the whole struct generic, struct Service<L: Logger>, which pushes the type parameter through every type and function that mentions Service. A boxed field keeps Service a plain type. The price is an indirect call per use, which for a logger or a clock is nothing.

Situation four: recursive types

A type that contains itself has infinite size unless an indirection breaks the cycle. The Expr enum above is the standard example: Add(Box<Expr>, Box<Expr>) stores two pointers instead of two full expressions, so Expr is a finite 24 bytes. This use of Box<T> is about size, not about dynamic dispatch, and it needs no trait object. Without the boxes the compiler reports "recursive type Expr has infinite size" and suggests exactly this fix. The evaluator walks the tree recursively and computes 2 + 3 * 4 = 14, as printed. Trees, linked lists and syntax trees all use this shape.

Situation five: returning different types from one function

When a function's branches produce different concrete types, a Box<dyn Trait> is the way to give them a common return type, as in the comparison of impl Trait and dyn Trait. The compiler suggests it when impl Trait fails with "incompatible types". It costs one allocation per call.

When to avoid it

Several situations look like they need a box and do not.

If the set of types is closed and small, use an enum. It stores the data inline, avoids the allocation and the pointer chase, and gets exhaustive match checking. The measurements in static versus dynamic dispatch show a mixed Vec<Box<dyn Op>> taking 2.5 ns per call against 0.7 ns for the enum.

If the callee only uses the value during the call, take &dyn Trait or impl Trait. As the counting run shows, neither allocates.

If every element has the same type, use Vec<T> or a generic. A Vec<Box<dyn Shape>> holding only circles pays for a vtable and a pointer per element for nothing.

If the closure or value is tiny and Copy, store it directly, as a field of generic type or a function pointer (fn(u32) -> u32), which needs no box because a function pointer is just an address.

Details that matter when you do use it

A Box<dyn Trait> defaults to 'static: Box<dyn Trait + 'static>. If the value borrows data, write the lifetime, Box<dyn Trait + 'a>. To move one across threads, add Send: Box<dyn Fn() + Send + 'static>, which is the bound thread::spawn and tokio::spawn expect. Not every trait can be boxed: those with generic methods or Self-returning methods are not dyn compatible, and the error and repairs are in E0038. To get the concrete type back, the Any trait supports downcasting, covered in Any and downcasting. And since Box<dyn Trait> is a single owner, sharing one among several holders needs Rc<dyn Trait> or Arc<dyn Trait> instead.

Taggeddynbox
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.