Static vs Dynamic Dispatch in Rust: Part 2
Part 2 of 2. Read part 1 for generics, monomorphization, trait objects, and vtables.
A Subtle Difference: Returning impl Trait
In the first part, we saw that impl Trait in function arguments behaves similarly to generics.
But its meaning in return position is a little different.
We’ll keep using the Notifier trait and its Email and Sms implementations from part 1.
Consider:
fn default_notifier() -> impl Notifier {
Email {
address: "hello@example.com".into(),
}
}This works.
The caller doesn’t need to know the exact return type, but the function’s implementation still returns one specific concrete type.
In this case, that type is Email.
We can think of impl Notifier here as hiding the concrete type from the caller without erasing it in the same way a trait object does.
Now consider this function:
fn choose_notifier(use_sms: bool) -> impl Notifier {
if use_sms {
Sms {
phone: "+15550000000".into(),
}
} else {
Email {
address: "hello@example.com".into(),
}
}
}This won’t compile.
Both types implement Notifier, so what’s the problem?
The important rule is:
A function returning impl Trait must resolve to one concrete return type.
Here, one branch returns Sms, while the other returns Email.
Those are different types.
If we genuinely need to choose between different implementations at runtime and return them through the same interface, we can use a trait object:
fn choose_notifier(use_sms: bool) -> Box<dyn Notifier> {
if use_sms {
Box::new(Sms {
phone: "+15550000000".into(),
})
} else {
Box::new(Email {
address: "hello@example.com".into(),
})
}
}This works because both branches return the same type:
Box<dyn Notifier>The underlying values differ, but their concrete types are hidden behind the trait-object interface.
An enum is another valid solution when all possible implementations are known. Using an enum keeps the choice explicit and can avoid trait-object dispatch.
Not Every Trait Supports dyn
There’s one more restriction worth understanding.
Not every Rust trait can be turned into a trait object.
Consider:
trait Processor {
fn process<T>(&self, value: T);
}This trait contains a generic method.
We can implement the trait for a concrete type, but we can’t directly create a dyn Processor trait object from this definition.
Why?
Imagine the method being called with many different types:
processor.process(42);
processor.process("Hello");
processor.process(vec![1, 2, 3]);Generic methods may require separate specialized implementations for different concrete type arguments.
But a trait object’s vtable must provide a fixed interface for dynamically dispatched methods.
It can’t simply contain an unbounded collection of implementations for every possible T.
This is one reason Rust imposes restrictions on traits used as trait objects.
These rules are called dyn compatibility, formerly known as object safety.
Some other restrictions involve associated constants, uses of Self, and methods returning opaque types.
One option for a generic method is to prevent that particular method from being dispatched through a trait object:
trait Processor {
fn process(&self, value: &str);
fn process_generic<T>(&self, _value: T)
where
Self: Sized,
{
// Default implementation
}
}Now process_generic() is explicitly limited to sized implementors.
A trait object can still use process(), but not process_generic().
This can be useful when most of a trait’s behavior should support dynamic dispatch while certain methods require access to the concrete implementing type.
Performance: Which One Is Faster?
We’ve established that static dispatch can avoid a level of indirection.
Does that mean we should always prefer it?
Not necessarily.
Let’s compare them.
| Static dispatch | Dynamic dispatch | |
|---|---|---|
| Common syntax | T: Trait, impl Trait | dyn Trait |
| Method resolution | Compile time | Runtime via vtable |
| Monomorphization | Generally yes | Not at the trait-object call boundary |
| Inlining opportunities | Usually greater | Often more limited |
| Code size | Can grow with specializations | Can reduce duplication |
| Different concrete types in one collection | Requires another abstraction | Supported through trait objects |
| Heap allocation | Not inherently required | Not inherently required |
There are some important nuances here.
Static dispatch isn’t automatically faster
Even though static dispatch allows direct calls and often enables inlining, that doesn’t necessarily mean the resulting program will always be faster.
Inlining can sometimes increase code size enough to hurt instruction-cache behavior.
And a function might perform expensive operations, such as network requests or filesystem I/O, where the overhead of a virtual method call is insignificant.
Dynamic dispatch isn’t necessarily slow
A vtable lookup and indirect call introduce additional work, but that overhead can be small compared with what the method actually does.
Modern compilers may also be able to optimize some dynamically dispatched calls when the concrete type can be determined.
The compiler isn’t obligated to preserve an indirect call merely because the source code used dyn.
Static dispatch can increase binary size
With generic code, multiple concrete types can lead to multiple specialized implementations.
That can increase the amount of generated machine code.
Trait objects can reduce some of this duplication by allowing shared code to operate through one interface.
These considerations can also affect compile times.
Ultimately, the right choice depends on the shape of the API, the performance characteristics of the application, and whether the flexibility of runtime polymorphism is valuable.
When Should You Use Each?
I generally think about the decision in terms of what my API needs to express.
If a function accepts some value implementing a trait and doesn’t need to store different concrete implementations together, generics are often the simplest choice.
fn notify(notifier: &impl Notifier) {
notifier.send("Hello!");
}The compiler can specialize the code for the concrete type.
On the other hand, if I’m building something like a plugin system, where different implementations need to be stored and handled through the same interface, trait objects are often more appropriate.
struct NotificationService {
providers: Vec<Box<dyn Notifier>>,
}Here, runtime flexibility is part of the design.
There isn’t a universal rule saying that one form is always better than the other.
And we shouldn’t introduce trait objects solely because a function happens to depend on a trait.
We should use them when type erasure and runtime polymorphism solve an actual problem.
Wrapping Up
Across these two parts, we’ve seen how static and dynamic dispatch let us write code that works with different implementations of the same trait.
But they accomplish this in fundamentally different ways.
With static dispatch, Rust uses concrete type information at compile time to determine which implementation to call. Generic functions are typically monomorphized into specialized implementations.
With dynamic dispatch, Rust uses trait objects and vtables to select the appropriate implementation at runtime.