17. Trait objects: dyn Trait and dynamic dispatch
Generics need the concrete type at compile time. Sometimes you only know it at runtime, for example with a list of plugins or middleware loaded from config. For that, use a trait object.
trait Middleware {
fn name(&self) -> &str;
fn handle(&self, path: &str) -> Result<(), String>;
}
struct Auth { token: String }
struct RateLimit { max_path_len: usize }
impl Middleware for Auth {
fn name(&self) -> &str { "auth" }
fn handle(&self, path: &str) -> Result<(), String> {
if path.starts_with("/admin") && self.token != "s3cret" { Err("forbidden".into()) } else { Ok(()) }
}
}
impl Middleware for RateLimit {
fn name(&self) -> &str { "limit" }
fn handle(&self, path: &str) -> Result<(), String> {
if path.len() > self.max_path_len { Err("uri too long".into()) } else { Ok(()) }
}
}
fn main() {
// A Vec of DIFFERENT concrete types, all behind the same interface.
let chain: Vec<Box<dyn Middleware>> = vec![
Box::new(RateLimit { max_path_len: 32 }),
Box::new(Auth { token: "wrong".into() }),
];
for path in ["/home", "/admin/users", "/a-very-long-path-that-should-be-rejected"] {
let verdict = chain.iter().try_for_each(|m| m.handle(path).map_err(|e| format!("{}: {e}", m.name())));
println!("{path:<45} {verdict:?}");
}
println!("{}", std::mem::size_of::<&dyn Middleware>()); // 16: data pointer + vtable pointer
}
17.1 What a trait object is in memory
&dyn Middleware and Box<dyn Middleware> are fat pointers: one
pointer to the data and one pointer to a vtable, a static table of
function pointers for that concrete type.
Box<dyn Middleware> vtable for Auth (static, one per type)
┌──────────────┐ ┌─────────────────────┐
│ data ptr ────┼──▶ Auth { token } │ drop_in_place<Auth> │
│ vtable ptr ──┼──────────────────────────▶ │ size, align │
└──────────────┘ │ Auth::name │
│ Auth::handle │
└─────────────────────┘
This is the same structure as a
Go interface value
(type word + data word), and the same thing the
C guide's function-pointer structs
build by hand. One difference from Go: the vtable pointer sits in the
reference, not in the object, so a plain Auth value carries no type
header at all.
17.2 Static vs dynamic dispatch: how to choose
Generics T: Trait |
Trait objects dyn Trait |
|
|---|---|---|
| Dispatch | static, inlinable | indirect call through the vtable |
| Mixed types in one collection | no | yes |
| Binary size | one copy per type | one copy |
| Typical use | hot paths, libraries | plugins, heterogeneous lists, reducing compile time |
17.3 Dyn compatibility (object safety)
Not every trait can become a dyn Trait. Methods that return Self, or
that have their own generic type parameters, cannot be put in a vtable,
because the caller would need to know the concrete type. For example,
Clone (fn clone(&self) -> Self) is not dyn-compatible. If one method
breaks this, add where Self: Sized to it to exclude it from the vtable and
keep the rest of the trait usable as dyn.
17.4 Downcasting
dyn Any can be downcast back to the concrete type: if let Some(a) = obj.downcast_ref::<Auth>().
This is Rust's version of Go's type switch, and you should need it about as
rarely. Error types are the main exception: anyhow::Error::downcast_ref
is how you check for one specific error inside an error chain.