
Rust Trait Objects and Dynamic Dispatch: Practical Patterns for Runtime Polymorphism
What a trait object is
A trait object is a dynamically dispatched value that implements a trait, but whose concrete type is erased at compile time. Instead of generating specialized code for each type, Rust uses a vtable to call the correct implementation at runtime.
You will commonly see trait objects in these forms:
&dyn Traitfor borrowed polymorphismBox<dyn Trait>for owned heap-allocated polymorphismArc<dyn Trait>orRc<dyn Trait>for shared ownership&mut dyn Traitfor mutable access through a trait
A trait object is useful when you need to treat different concrete types uniformly, but you do not know the exact type at compile time.
Example use case
Imagine a reporting system where multiple data sources produce the same kind of output:
- CSV importer
- JSON importer
- Database-backed importer
Each source can implement a ReportSource trait, and the application can store them in a single collection.
Static dispatch vs dynamic dispatch
Rust usually prefers static dispatch through generics:
fn process<T: ReportSource>(source: T) {
println!("{}", source.name());
}This is fast and inlinable, but it requires the concrete type to be known at compile time. If you need a Vec containing multiple implementations, generics alone are not enough.
With trait objects:
fn process(source: &dyn ReportSource) {
println!("{}", source.name());
}The compiler emits one function body, and method calls are resolved at runtime through the vtable.
Trade-offs
| Approach | When to use | Advantages | Costs |
|---|---|---|---|
| Generics | Type known at compile time | Fast, inlinable, zero runtime dispatch | Code bloat, less flexible collections |
| Trait objects | Type chosen at runtime | Heterogeneous collections, plugin-like design | Indirection, vtable lookup, heap allocation often needed |
In practice, the right choice depends on whether flexibility or maximum compile-time optimization matters more.
Defining a trait object-safe API
Not every trait can become a trait object. To be used as dyn Trait, a trait must be object safe.
A trait is generally object safe when:
- Methods do not return
Self - Methods do not use generic type parameters
- Methods can be called through a trait reference
- The trait does not require
Self: Sizedfor all methods
Object-safe example
trait ReportSource {
fn name(&self) -> &str;
fn fetch(&self) -> String;
}This trait is object safe because both methods take &self and return concrete types.
Non-object-safe example
trait BadTrait {
fn clone_self(&self) -> Self;
}This is not object safe because the return type depends on the unknown concrete type.
Fixing object safety
If you need cloning behavior, use an object-safe helper pattern:
trait CloneReportSource {
fn clone_box(&self) -> Box<dyn CloneReportSource>;
}Or split the API into two traits:
- one object-safe trait for runtime use
- one generic trait for compile-time use
This separation is a common design technique in Rust libraries.
Building a heterogeneous collection
A classic trait object use case is storing different implementations in one vector.
trait ReportSource {
fn name(&self) -> &str;
fn fetch(&self) -> String;
}
struct CsvSource {
path: String,
}
struct JsonSource {
endpoint: String,
}
impl ReportSource for CsvSource {
fn name(&self) -> &str {
"csv"
}
fn fetch(&self) -> String {
format!("reading CSV from {}", self.path)
}
}
impl ReportSource for JsonSource {
fn name(&self) -> &str {
"json"
}
fn fetch(&self) -> String {
format!("fetching JSON from {}", self.endpoint)
}
}
fn main() {
let sources: Vec<Box<dyn ReportSource>> = vec![
Box::new(CsvSource {
path: "data/report.csv".into(),
}),
Box::new(JsonSource {
endpoint: "https://api.example.com/report".into(),
}),
];
for source in sources.iter() {
println!("[{}] {}", source.name(), source.fetch());
}
}This pattern is common in applications that need extensibility without exposing concrete types everywhere.
Why Box<dyn Trait>?
A trait object has an unknown size at compile time, so it cannot usually live directly on the stack as a plain value. Box gives it a stable heap allocation and a known pointer size.
Choosing between &dyn Trait, Box<dyn Trait>, and Arc<dyn Trait>
The wrapper you choose depends on ownership and lifetime requirements.
| Type | Best for | Notes |
|---|---|---|
&dyn Trait | Temporary borrowed access | No allocation, lifetime tied to caller |
Box<dyn Trait> | Owned polymorphic values | Common choice for collections and APIs |
Arc<dyn Trait> | Shared immutable polymorphism | Useful across threads with Send + Sync |
Rc<dyn Trait> | Shared single-threaded polymorphism | Lower overhead than Arc, not thread-safe |
Practical guidance
- Use
&dyn Traitwhen a function only needs to inspect or invoke behavior temporarily. - Use
Box<dyn Trait>when the callee should own the value. - Use
Arc<dyn Trait>when multiple parts of the program need shared access, especially in async or concurrent systems.
Designing trait object-friendly APIs
Good trait object APIs are small, focused, and stable. They should expose behavior, not implementation details.
Prefer narrow interfaces
Instead of one large trait with many methods, define smaller traits:
trait Readable {
fn read(&self) -> String;
}
trait Writable {
fn write(&mut self, data: &str);
}This makes object safety easier and allows consumers to depend only on what they need.
Avoid generic methods in object-safe traits
This is problematic:
trait Serializer {
fn serialize<T>(&self, value: &T) -> String;
}The method is generic, so it cannot be called through dyn Serializer. A better design is to move the generic behavior outside the trait or use an enum/visitor pattern if runtime type handling is required.
Use associated types carefully
Associated types can be useful, but they may complicate object safety if they appear in method signatures that depend on Self. Keep trait object APIs concrete where possible.
Downcasting when you need concrete behavior
Sometimes you have a trait object but need to recover the underlying concrete type. Rust supports this through Any and downcasting.
use std::any::Any;
trait Plugin: Any {
fn name(&self) -> &str;
fn as_any(&self) -> &dyn Any;
}
struct LoggerPlugin;
impl Plugin for LoggerPlugin {
fn name(&self) -> &str {
"logger"
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn main() {
let plugin: Box<dyn Plugin> = Box::new(LoggerPlugin);
if let Some(_logger) = plugin.as_any().downcast_ref::<LoggerPlugin>() {
println!("Found logger plugin");
}
}Best practices for downcasting
- Use it sparingly; it weakens abstraction.
- Prefer explicit trait methods when possible.
- Reserve it for plugin systems, registries, and integration layers where concrete type checks are unavoidable.
Performance considerations
Trait objects introduce a small runtime cost:
- one pointer indirection
- one vtable lookup per method call
- possible heap allocation when boxed
For many applications, this overhead is negligible compared to I/O, parsing, network calls, or database access. But in tight loops or performance-critical code, generics may be better.
Rule of thumb
Use trait objects when:
- you need runtime selection of behavior
- you need heterogeneous storage
- API simplicity matters more than maximum throughput
Use generics when:
- the concrete type is known at compile time
- the code is in a hot path
- you want inlining and specialization
Common pitfalls
Forgetting object safety
If a trait is not object safe, dyn Trait will not compile. When this happens, inspect the trait for:
Selfin return position- generic methods
- methods requiring
Self: Sized
Overusing trait objects
Trait objects are powerful, but they can make code harder to optimize and reason about if used everywhere. Do not replace every generic API with dynamic dispatch by default.
Leaking abstraction boundaries
If callers need to downcast frequently, the trait may be too vague. Consider redesigning the API so the trait itself exposes the needed operations.
Ignoring ownership semantics
A borrowed trait object cannot outlive its source. If you need to store values, choose an owned wrapper such as Box or Arc.
A practical pattern: runtime-extensible processors
A common architecture is a pipeline of processors that all implement the same trait.
trait Processor {
fn process(&self, input: &str) -> String;
}
struct Trim;
struct Uppercase;
impl Processor for Trim {
fn process(&self, input: &str) -> String {
input.trim().to_string()
}
}
impl Processor for Uppercase {
fn process(&self, input: &str) -> String {
input.to_uppercase()
}
}
fn run_pipeline(processors: &[Box<dyn Processor>], input: &str) -> String {
let mut current = input.to_string();
for processor in processors {
current = processor.process(¤t);
}
current
}This design is easy to extend. New processors can be added without changing the pipeline logic. That makes trait objects a strong fit for configuration-driven systems and modular applications.
When trait objects are the right choice
Trait objects shine when your design needs one or more of the following:
- runtime polymorphism
- plugin architectures
- dependency injection
- GUI callbacks and event handlers
- collections of mixed concrete types
- stable public APIs that should not expose generics
They are especially valuable in application code and library boundaries where flexibility matters more than absolute performance.
When to avoid them
Prefer static dispatch if:
- the set of types is small and known
- the code is performance-sensitive
- you need generic methods or
Self-returning APIs - the abstraction is purely internal and does not need runtime selection
A good Rust codebase often uses both approaches: generics for core logic, trait objects at the edges.
