Static and dynamic dispatches are important concepts to understand how your code is compiled and works in runtime, and how you can solve certain day-to-day coding problems (related to polymorphism).
Static dispatch (also called "early binding") happens only at compile time. The compiler generates separate code for each concrete type that is used. In Rust static dispatch is a default way for polymorphism and is introduced simply by generics (parametric polymorphism): MyType<T, S, F>
.
Dynamic dispatch (sometimes called "late binding") happens at runtime. The concrete used type is erased at compile time, so compiler doesn't know it, therefore generates vtable
which dispatches call at runtime and comes with a performance penalty. In Rust dynamic dispatch is introduced via trait objects: &dyn MyTrait
, Box<dyn MyTrait>
.
You have to use dynamic dispatch in situations where type erasure is required. If the problem can be solved with a static dispatch then you'd better to do so to avoid performance penalties. The most common example when you cannot use static dispatch and have to go with dynamic dispatch are heterogeneous collections (where each item is potentially a different concrete type, but each one implements MyTrait
).
For better understanding static and dynamic dispatches purpose, design, limitations and use cases, read through the following articles:
- Rust Blog: Abstraction without overhead: traits in Rust
- Joshleeb: Traits and Trait Objects in Rust
- Rust Book: 17.2. Using Trait Objects That Allow for Values of Different Types
- Adam Schwalm: Exploring Dynamic Dispatch in Rust
- Marco Amann: Rust Dynamic Dispatching deep-dive
- Nicholas Matsakis: Dyn async traits, part 2
- Armin Ronacher: Rust Any Part 1: Extension Maps in Rust
- Armin Ronacher: Rust Any Part 2: As-Any Hack
The other reason to go with static dispatch is that except performance penalties, trait objects have the other major downside: not all traits can be used for creating trait objects. A trait needs to meet special object safety requirements:
- The trait cannot require
Self: Sized
.- Method references the
Self
type in its arguments or return type.- Method has generic type parameters.
- Method has no receiver.
- The trait cannot contain associated constants.
- The trait cannot use
Self
as a type parameter in the supertrait listing.
This can lead to quite tricky and non-obvious situations when writing code.
For better understanding object safety purpose, design and limitations, read through the following articles:
- Rust Book: 17.2. Object Safety Is Required for Trait Objects
- Rust Reference: 6.1. Traits: Object Safety
- Nicholas Matsakis: Dyn async traits, part 2
In situations where you need to deal with different types, but all possible types form a closed set (you know all the used types), dynamic dispatch can be replaced with a static dispatch in a price of some enum
-based boilerplate.
For example the following dynamically dispatched code:
trait SayHello {
fn say_hello(&self);
}
struct English;
impl SayHello for English {
fn say_hello(&self) {
println!("Hello!")
}
}
struct Spanish;
impl SayHello for Spanish {
fn say_hello(&self) {
println!("Hola!")
}
}
// We have to use trait object here to contain different types.
let greetings: Vec<Box<dyn SayHello>> = vec![
Box::new(English),
Box::new(Spanish),
];
Can be refactored in the following way (as far as we know that only English
and Spanish
types will be used):
trait SayHello {
fn say_hello(&self);
}
struct English;
impl SayHello for English {
fn say_hello(&self) {
println!("Hello!")
}
}
struct Spanish;
impl SayHello for Spanish {
fn say_hello(&self) {
println!("Hola!")
}
}
enum Language {
English(English),
Spanish(Spanish),
}
impl SayHello for Language {
fn say_hello(&self) {
match self {
Language::English(l) => l.say_hello(),
Language::Spanish(l) => l.say_hello(),
}
}
}
// We contain different types without using trait objects.
let greetings: Vec<Language> = vec![English, Spanish];
There is also a handy enum_dispatch crate, which generates this boilerplate automatically in some cases. It has illustrative benchmarks about performance gains of using enum
for dispatching.
Static dispatch with type parameters has a downside of generating rather a lot of code (for each type), bloating binary size and potentially pessimizing execution cache usage. However, often generics aren’t really needed for speed, but for ergonomics.
The canonical solution of this problem is to factor out an inner method that contains all of the code minus the generic conversions, and leave the outer method as a shell. For example:
pub fn this<I: Into<String>>(i: I) -> usize {
// do something really complicated with `i.into()`
// potentially spanning multiple pages of code
}
becomes
#[inline]
pub fn this<I: Into<String>>(i: I) -> usize {
_this_inner(i.into())
}
fn _this_inner(i: String) -> usize {
// same code as above without the conversion
}
This ensures only the conversion gets monomorphized, leading to leaner code and compile-time performance wins.
There is a handy momo crate, which generates this boilerplate automatically in some cases. Read through its explanation article:
Estimated time: 1 day
Given the following Storage
abstraction and User
entity:
trait Storage<K, V> {
fn set(&mut self, key: K, val: V);
fn get(&self, key: &K) -> Option<&V>;
fn remove(&mut self, key: &K) -> Option<V>;
}
struct User {
id: u64,
email: Cow<'static, str>,
activated: bool,
}
Implement UserRepository
type with injectable Storage
implementation, which can get, add, update and remove User
in the injected Storage
. Make two different implementations: one should use dynamic dispatch for Storage
injecting, and the other one should use static dispatch.
Prove your implementation correctness with tests.
After completing everything above, you should be able to answer (and understand why) the following questions:
- What is dispatch? When a function call represents a dispatch and when not?
- How does static dispatch work?
- How does dynamic dispatch work? Why is it required? Which limitations does it have in Rust? Why does it have them?
- When dynamic dispatch can be replaced with static dispatch? When not? What are the trade-offs?
- How can we reduce the size of compiler-generated code when using static dispatch?