Static vs Dynamic Dispatch in Rust: Part 1
Part 1 of 2. Part 2 covers return types, dyn compatibility, and performance tradeoffs.
If you’ve worked with traits in Rust, you’ve probably encountered these two function signatures:
fn process<T: Processor>(processor: &T) {}
fn process(processor: &dyn Processor) {}At first glance, they seem to accomplish the same thing.
Both accept values implementing the Processor trait, and both allow us to call methods defined by that trait.
But underneath, they work quite differently.
The first uses static dispatch, while the second uses dynamic dispatch.
This distinction affects how Rust compiles our code, how methods are called at runtime, and even the size of our resulting binary.
Let’s explore how they work.
The Problem
Imagine we’re building a notification system that supports different delivery methods.
For now, we have two:
- SMS
We want both to expose a common interface so that the rest of our application doesn’t have to worry about how messages are delivered.
We can define a trait:
trait Notifier {
fn send(&self, message: &str);
}And two implementations:
struct Email {
address: String,
}
struct Sms {
phone: String,
}
impl Notifier for Email {
fn send(&self, message: &str) {
println!("Email to {}: {message}", self.address);
}
}
impl Notifier for Sms {
fn send(&self, message: &str) {
println!("SMS to {}: {message}", self.phone);
}
}Nothing unusual so far.
We have two completely different types implementing the same trait.
Now imagine we want a function that accepts either one and sends a message.
How should we write it?
Static Dispatch Using Generics
One approach is to use generics.
fn notify<T: Notifier>(notifier: &T, message: &str) {
notifier.send(message);
}Here, T represents any type that implements Notifier.
We can call this function with either implementation:
fn main() {
let email = Email {
address: "hello@example.com".into(),
};
let sms = Sms {
phone: "+15550000000".into(),
};
notify(&email, "Hello!");
notify(&sms, "Hello!");
}Both calls compile and work as expected.
But there’s an interesting question here.
Our function accepts a generic type T, yet Email and Sms have completely different implementations of send().
How does Rust know which one to call?
The answer is monomorphization.
Understanding Monomorphization
Monomorphization is the process by which Rust generates specialized code for the concrete types used with a generic function.
Consider our function again:
fn notify<T: Notifier>(notifier: &T, message: &str) {
notifier.send(message);
}When we call it with an Email, the compiler knows that T is Email.
When we call it with an Sms, the compiler knows that T is Sms.
Conceptually, Rust can turn our generic function into something like this:
fn notify_email(notifier: &Email, message: &str) {
notifier.send(message);
}
fn notify_sms(notifier: &Sms, message: &str) {
notifier.send(message);
}These aren’t necessarily the exact functions Rust emits. This is a simplified representation of what specialization achieves.
Instead of keeping a generic function that must determine the implementation at runtime, the compiler generates code specialized for the concrete types.
Our calls can now be thought of as:
The important detail is that the implementation to call is known at compile time.
That’s static dispatch.
And because the compiler knows the concrete type, it has more opportunities to optimize the resulting code.
For example, it may inline Email::send() directly into the caller, eliminating the overhead of a separate function call.
Of course, whether inlining actually happens depends on the optimizer.
One tradeoff is that generating specialized implementations can increase compilation work and binary size, particularly when large generic functions are instantiated for many types. The compiler can sometimes merge or eliminate redundant code, so duplication isn’t always one-to-one.
What About impl Trait?
Rust also allows us to write:
fn notify(notifier: &impl Notifier, message: &str) {
notifier.send(message);
}This is largely equivalent to:
fn notify<T: Notifier>(notifier: &T, message: &str) {
notifier.send(message);
}In argument position, impl Trait introduces an anonymous generic type parameter.
It still uses static dispatch.
That means these two approaches have the same general dispatch characteristics:
fn first<T: Notifier>(notifier: &T) {}
fn second(notifier: &impl Notifier) {}There are differences between named generic parameters and impl Trait, particularly when we need to express relationships between parameter types.
But neither of these signatures introduces dynamic dispatch.
This is worth remembering because the syntax can be misleading.
impl Trait and dyn Trait are not interchangeable.
We’ll look at another important difference involving return types in part 2.
Dynamic Dispatch Using Trait Objects
Now let’s implement our notification function differently.
Instead of using generics, we’ll use a trait object:
fn notify(notifier: &dyn Notifier, message: &str) {
notifier.send(message);
}Notice the dyn keyword.
It tells Rust that we’re working with a trait object.
We can still pass either implementation:
let email = Email {
address: "hello@example.com".into(),
};
let sms = Sms {
phone: "+15550000000".into(),
};
notify(&email, "Hello!");
notify(&sms, "Hello!");But the mechanism is different.
With generics, the compiler knows the concrete type for each specialized version of our function.
With &dyn Notifier, our function operates through a trait object without knowing whether the underlying value is an Email, Sms, or some other type implementing Notifier.
The concrete type is erased from the interface.
So how does Rust know which implementation of send() to call?
That’s where virtual method tables, commonly called vtables, come in.
Understanding Vtables
A trait object reference such as:
&dyn Notifieris represented using two pieces of pointer information.
- A pointer to the underlying value.
- A pointer to a vtable associated with that value’s concrete type and trait implementation.
The vtable provides the information needed to call the appropriate trait methods.
Conceptually, an &dyn Notifier pointing to an Email looks like this:
This is a conceptual view rather than a guarantee about the exact binary layout or ordering of vtable entries.
The vtable contains the function pointer needed to invoke the correct implementation of send().
When we write:
notifier.send("Hello!");Rust can conceptually perform the following steps:
- Access the vtable associated with the trait object.
- Retrieve the function pointer for
send(). - Invoke that function with the underlying data pointer and message.
So, instead of knowing the exact implementation at compile time, the program uses information available at runtime to select the implementation.
That’s dynamic dispatch.
The distinction becomes clearer when we compare the two:
The method implementation still exists as compiled machine code.
Dynamic dispatch doesn’t mean Rust is interpreting the method or generating its implementation at runtime.
The difference is how the call target is selected.
Why Would We Want Dynamic Dispatch?
At this point, you might be wondering why we’d use dynamic dispatch at all.
After all, static dispatch avoids the extra indirection and provides the compiler with more optimization opportunities.
The reason is flexibility.
Let’s say our notification system needs to maintain a collection of notification providers.
We might try:
let notifiers = vec![email, sms];But this won’t compile.
A Vec<T> requires all its elements to have the same type.
Even though Email and Sms implement the same trait, they’re still different concrete types.
We could use an enum if we have a fixed set of implementations.
But what if our system supports implementations defined by external libraries or application plugins?
This is where trait objects become useful.
let notifiers: Vec<Box<dyn Notifier>> = vec![
Box::new(Email {
address: "hello@example.com".into(),
}),
Box::new(Sms {
phone: "+15550000000".into(),
}),
];Now our vector can store values of different concrete types through a common trait-object interface.
We can iterate over them:
for notifier in ¬ifiers {
notifier.send("System notification");
}And each provider executes its own implementation.
This works because each Box<dyn Notifier> has the same known pointer representation, even though the underlying concrete values may have different sizes.
The vector doesn’t need to know whether a particular element is an Email or an Sms.
It only needs to know that the value implements Notifier.
That’s one of the primary reasons to use dynamic dispatch.
Why Do We Need Box<dyn Trait>?
You might have noticed that we used:
Box<dyn Notifier>rather than just:
dyn NotifierWhy?
Because dyn Notifier is a dynamically sized type.
The compiler doesn’t know its concrete size from the trait alone.
An Email might contain a few fields, while another implementation could be substantially larger.
Rust generally needs to know the size of a value to store it directly in a local variable or inside a vector.
So we place the trait object behind a pointer.
Common choices include:
&dyn Notifier
Box<dyn Notifier>
Arc<dyn Notifier>Each serves a different purpose.
&dyn Notifier borrows an existing value. Creating this borrowed trait-object reference doesn’t inherently require a heap allocation.
Box<dyn Notifier> owns its underlying value and normally stores it on the heap.
Arc<dyn Notifier> allows shared ownership through atomic reference counting.
Notice that dynamic dispatch itself doesn’t require heap allocation.
The heap allocation in our vector example comes from the choice to use Box, not from the dyn keyword.
Wrapping Up
Static dispatch uses the concrete type to choose an implementation at compile time. Generics and argument-position impl Trait let Rust specialize the code through monomorphization.
Dynamic dispatch works through a trait object and its vtable, allowing different concrete types to share an interface.
Neither dyn nor dynamic dispatch inherently requires heap allocation; that depends on the pointer type we choose.
In part 2, we’ll look at return-position impl Trait, which traits support dyn, and how to choose between the two approaches in practice.