Type erasure with dyn and Any
Generics are erased by the compiler into one function per type, which is fast and makes the binary larger. A trait object is the other direction: one value whose real type is forgotten at run time and remembered in a table beside it. The two coexist, and the choice between them is the choice between a bigger binary and a pointer.
This lesson puts closures of different types into one vector, stores two shapes behind one trait, asks a value for its type with Any, measures what each shape costs and ends with the method a trait object cannot call.
Box<dyn Fn>
Every closure has a type of its own, even two closures with the same signature and the same body written on two lines. A Vec holds one type, so a vector of steps has to forget which closure type each element is: Box<dyn Fn(i32) -> i32> keeps the behaviour and drops the name.
The program builds a pipeline of two steps and runs a value through it. The first adds one and the second multiplies by ten, so the order is visible in the answer.
fn main() {
let mut pipeline: Vec<Box<dyn Fn(i32) -> i32>> = Vec::new();
pipeline.push(Box::new(|x| x + 1));
pipeline.push(Box::new(|x| x * 10));
let mut value = 4;
for step in &pipeline {
value = step(value);
}
println!("{}", value);
}
rustc --edition 2024 funcs.rs
./funcs
50
Five is four plus one, and fifty is five times ten, so the two boxes ran in the order they were pushed. The call goes through the vtable of each box, which is why the loop needs no match over the steps. The lesson on closures explains why the trait is Fn rather than FnMut or FnOnce.
A closure that captures a value and changes it is FnMut, and the vector then needs Box<dyn FnMut(i32) -> i32> and a mutable binding for the loop. The bound is part of the type, so the compiler tells a caller which kind of call the stored step allows.
A trait object written by hand
The same mechanism works with a trait of one's own. A trait with two methods becomes a type that has those two methods and no fields: the vtable holds the addresses, and the data pointer holds the value.
The program stores a square and a circle in one vector of Box<dyn Shape>, prints each one and then sums the areas through the same trait.
trait Shape {
fn name(&self) -> &'static str;
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
struct Circle {
radius: f64,
}
impl Shape for Square {
fn name(&self) -> &'static str {
"square"
}
fn area(&self) -> f64 {
self.side * self.side
}
}
impl Shape for Circle {
fn name(&self) -> &'static str {
"circle"
}
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Square { side: 3.0 }),
Box::new(Circle { radius: 2.0 }),
];
for shape in &shapes {
println!("{} {:.2}", shape.name(), shape.area());
}
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("{total:.2}");
}
rustc --edition 2024 shapes.rs
./shapes
square 9.00
circle 12.57
21.57
The two calls in the loop dispatch to two different functions, and the sum adds them without knowing which is which. That is the C++ virtual call, with the vtable chosen by the concrete type at the Box::new and stored in the fat pointer. The lesson on trait objects covers object safety, which is the rule that decides whether a trait can be used this way at all.
Any and downcast
Some values arrive with a type that is known only at run time: a configuration entry, a message from a queue, a row of an untyped table. std::any::Any is the trait for them, and downcast_ref asks whether the value is of a given type and returns a reference when it is.
The program describes three values: an integer, a string slice and a float. The function knows the first two and reports the third as unknown.
use std::any::Any;
fn describe(value: &dyn Any) -> String {
if let Some(number) = value.downcast_ref::<i32>() {
format!("int {number}")
} else if let Some(text) = value.downcast_ref::<&str>() {
format!("text {text}")
} else {
"unknown".to_string()
}
}
fn main() {
println!("{}", describe(&42));
println!("{}", describe(&"hi"));
println!("{}", describe(&3.5));
}
rustc --edition 2024 any.rs
./any
int 42
text hi
unknown
The type identifier in the vtable is what makes the comparison possible, and it is why Any requires 'static: a value with a borrow in it could outlive the data it points at while the identifier stayed the same. The lesson on lifetimes explains the bound that the trait carries.
Any is the last resort of a design, not the first. A caller that downcasts knows the concrete types in advance, so an enum of those types is usually clearer and makes the compiler check that every case is handled. The trait earns its place at a boundary that cannot know the types, such as a plugin registry or a test helper.
What each one costs
A reference to a trait object is a fat pointer: one word for the address of the value and one for the address of the vtable. A boxed trait object adds the heap allocation on top. A generic function has neither, because the compiler writes a version per type and calls it directly.
The program prints four sizes. The square is two words of data, and a reference to it as a shape is four words, because the vtable pointer is the second half. A closure that captures nothing is zero bytes, and a reference to it as a function object is still four words.
use std::any::Any;
use std::mem::size_of;
trait Shape {
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
fn main() {
let closure = |x: i64| x + 1;
let shape: Box<dyn Shape> = Box::new(Square { side: 2.0 });
println!("{} {}", size_of::<Square>(), size_of::<&dyn Shape>());
println!("{} {}", size_of::<Box<dyn Shape>>(), size_of::<Box<dyn Any>>());
println!("{} {}", size_of_val(&closure), size_of::<&dyn Fn(i64) -> i64>());
println!("{}", shape.area());
}
rustc --edition 2024 sizes.rs
./sizes
8 16
16 16
0 16
4
Sixteen bytes is two pointers on a sixty-four-bit machine, and it is the whole price of the dynamic dispatch: the value is not copied, and the call is one indirect jump. A closure with no captures has no data at all, which is why the compiler can usually inline it when the type is known; behind a Box, the vtable call cannot be inlined. The lesson on trait bounds covers the static side of the same choice.
The method outside the trait
A trait object knows the methods of its trait and nothing else. An inherent method of the concrete type is invisible through dyn Shape even when the value is a square, because the vtable has only the trait functions in it.
trait Shape {
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Square {
fn width(&self) -> f64 {
self.side
}
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
}
fn main() {
let shape: Box<dyn Shape> = Box::new(Square { side: 3.0 });
println!("{}", shape.width());
}
rustc --edition 2024 width_err.rs
error[E0599]: no method named `width` found for struct `Box<dyn Shape>` in the current scope
--> width_err.rs:23:26
|
23 | println!("{}", shape.width());
| ^^^^^ method not found in `Box<dyn Shape>`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0599`.
The fix is to put width into the trait, or to downcast to Square with Any and call it there. The first is the design that keeps every shape equal; the second is the escape hatch that says the caller knows more than the trait. The next lesson replaces the escape hatch with a match the compiler can check.
Exercises
- Add a third step to the pipeline and print the value after each one.
- Replace the vector of shapes with an enum and compare the two versions in size and in call cost.
- Store an integer and a string in a HashMap<String, Box<dyn Any>> and read both back with downcast_ref.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What is a
dyn Traitvalue?Answer
A value of an unknown concrete type plus a table of the trait methods
The data pointer and the vtable pointer together make the trait object.
-
Why does a vector of closures need
Box<dyn Fn>?Answer
Each closure has a type of its own, and the vector holds one type
The box erases the type of each closure to the shared trait and the signature.
-
What does
downcast_ref::<T>()return?Answer
Some(&T)when the value is aT, andNoneotherwiseThe comparison uses the type identifier, and a mismatch is not an error.
-
How large is
&dyn Shapeon a 64-bit machine?Answer
Two words: the data pointer and the vtable pointer
A trait object reference is a fat pointer, which is why it cannot be cast to one word.
-
Which methods can be called on a
Box<dyn Shape>?Answer
Only the methods declared in
ShapeThe vtable holds the trait methods, and inherent methods are not in it.
-
What does this program print?
fn main() { let mut steps: Vec<Box<dyn Fn(i32) -> i32>> = Vec::new(); steps.push(Box::new(|x| x + 1)); steps.push(Box::new(|x| x * 10)); let mut value = 4; for step in &steps { value = step(value); } println!("{value}"); }Answer
50
Four plus one is five and five times ten is fifty.
-
What does this program print?
trait Shape { fn area(&self) -> f64; } struct Square { side: f64, } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Square { side: 2.0 }), Box::new(Square { side: 3.0 }), ]; let total: f64 = shapes.iter().map(|s| s.area()).sum(); println!("{total}"); }Answer
13
The areas are four and nine, and the sum is thirteen.
-
What does this program print?
use std::any::Any; fn describe(value: &dyn Any) -> &'static str { if value.downcast_ref::<i32>().is_some() { "int" } else if value.downcast_ref::<&str>().is_some() { "text" } else { "other" } } fn main() { println!("{} {} {}", describe(&7), describe(&"x"), describe(&1.5)); }Answer
int text other
The integer and the string slice match, and the float does not.
-
What does this program print?
use std::mem::size_of; trait Shape { fn area(&self) -> f64; } struct Square { side: f64, } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } fn main() { let closure = |x: i64| x + 1; println!("{} {}", size_of::<&dyn Shape>(), size_of_val(&closure)); }Answer
16 0
The trait object reference is two words, and the closure captures nothing.
-
What is the first line of the compiler output for this program?
trait Shape { fn area(&self) -> f64; } struct Square { side: f64, } impl Square { fn width(&self) -> f64 { self.side } } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } fn main() { let shape: Box<dyn Shape> = Box::new(Square { side: 3.0 }); println!("{}", shape.width()); }Answer
error[E0599]: no method named
widthfound for structBox<dyn Shape>in the current scopeThe vtable holds the trait methods only, so the inherent method is not reachable.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev