Polymorphism, the ability to write code that operates on values of different types through a common interface, is a cornerstone of expressive software design. In Rust, traits provide the foundation for polymorphism. A common way to achieve runtime polymorphism is using trait objects, denoted by dyn Trait
. While incredibly flexible, this dynamic dispatch mechanism carries certain runtime performance costs.1
However, Rust offers another powerful pattern for polymorphism, particularly when dealing with a fixed, known set of types: using standard enums combined with match
expressions. This approach leverages Rust’s strong type system to achieve static dispatch, often resulting in significant performance improvements compared to dyn Trait
.4
This post explores the concepts of static and dynamic dispatch in Rust, delves into how enums facilitate static dispatch, provides a practical example using geometric shapes, and compares the trade-offs between enum-based static dispatch and dyn Trait
-based dynamic dispatch.
Dispatch Deep Dive: Static vs. Dynamic
At its core, “dispatch” is the mechanism that determines which specific function or method implementation gets executed when there are multiple possibilities, typically arising from polymorphism.2 Rust primarily uses two forms: static and dynamic.
Static Dispatch: The Compile-Time Detective
Static dispatch resolves the exact function to call at compile time.2 The compiler has full knowledge of the concrete types involved.
Mechanism: Monomorphization
Rust achieves static dispatch for generic code (functions like fn process<T: MyTrait>(item: T)
or using impl Trait
syntax like fn process(item: impl MyTrait)
) through a process called monomorphization.2 During compilation, the Rust compiler generates a specialized, non-generic version of the function for each concrete type it’s actually used with.8
For instance, consider a simple generic function:
Rust
use std::fmt::Display;
fn print_number<T: Display>(x: T) {
println!("Number: {}", x);
}
fn main() {
print_number(5); // Compiler generates print_number_i32
print_number(10.5); // Compiler generates print_number_f64
}
The compiler effectively creates print_number_i32
and print_number_f64
, replacing the generic calls with direct calls to these specialized versions.2
Performance Benefits
The primary advantage of static dispatch is speed. Because the function call is resolved at compile time, it performs like a direct function call with minimal overhead.10 More importantly, since the compiler knows the exact code being called, it can perform aggressive optimizations, most notably inlining.3 Inlining replaces the function call with the body of the called function, eliminating call overhead and potentially enabling further optimizations within the combined code block. This ability to inline and optimize is often the most significant performance differentiator compared to dynamic dispatch, which typically prevents such optimizations.13
Trade-offs
This performance comes at a cost. Monomorphization can lead to code bloat, as multiple copies of the generic function’s code exist in the final binary, one for each concrete type.2 This can increase the binary size and potentially slow down compile times, especially in projects with extensive use of generics across many types.9 While techniques exist to mitigate excessive monomorphization bloat (like manually separating generic and non-generic parts of functions, sometimes called “trampolining” 18), they add complexity.
Dynamic Dispatch: The Runtime Resolver
Dynamic dispatch, in contrast, defers the decision of which function to call until runtime.1 The compiler doesn’t know the concrete type implementing a trait at the point of the call.
Mechanism: Trait Objects (dyn Trait
) and Vtables
Rust implements dynamic dispatch using trait objects, indicated by the dyn
keyword (e.g., &dyn MyTrait
, Box<dyn MyTrait>
).1 A trait object allows code to interact with any type that implements a specific trait without knowing the exact type at compile time.
Under the hood, trait objects are typically represented as fat pointers.19 A fat pointer contains two regular pointers:
- A pointer to the actual instance data (the struct or enum value).
- A pointer to a virtual table (vtable).3
The vtable is a crucial data structure, essentially a lookup table (often a struct of function pointers) generated by the compiler for each type that implements the trait.3 It contains pointers to the specific machine code implementations of the trait’s methods for that particular type. It also stores metadata like the size and alignment of the type, and a pointer to the type’s destructor (drop function).3
When a method is called on a trait object (e.g., trait_object.method()
), the program performs these steps at runtime:
- Uses the vtable pointer within the fat pointer to find the vtable.
- Looks up the correct function pointer for
method()
within that vtable. - Calls the function using that pointer, passing the data pointer as the
self
argument.3
Performance Costs
Dynamic dispatch introduces several overheads:
- Vtable Lookup: Each method call involves an extra pointer indirection to find the function pointer in the vtable.1 While often small, this cost can add up in hot loops.
- Inhibition of Optimizations: The most significant cost is often indirect: dynamic dispatch usually prevents the compiler from inlining the method call or performing other optimizations across the call boundary, because the exact code to be executed isn’t known until runtime.4
- Pointer Size: Fat pointers occupy twice the memory of regular pointers (e.g., 16 bytes vs. 8 bytes on a 64-bit system).19
- Heap Allocation: Trait objects are often used with
Box<dyn Trait>
, which involves heap allocation for the object itself, adding further overhead.4
Trade-offs
Despite the performance costs, dynamic dispatch offers significant flexibility. It allows creating heterogeneous collections (like Vec<Box<dyn MyTrait>>
) that can hold objects of different concrete types implementing the same trait, where the types might not be known at compile time.25 This is essential for scenarios like GUI frameworks handling diverse widgets, plugin systems where types are loaded dynamically, or network services handling various request types.26
Furthermore, dynamic dispatch can lead to smaller binary sizes and potentially faster compile times compared to heavy monomorphization, as only one version of the function operating on the trait object needs to be compiled, rather than specialized versions for every type.2 This compile-time vs. runtime trade-off is important; sometimes, accepting a small runtime cost is preferable to significantly longer builds or larger executables, especially in application code rather than performance-critical libraries.16
Enums to the Rescue: Static Dispatch for Known Types
When the set of types that need to be handled polymorphically is fixed and known at compile time, Rust enums offer an elegant and performant alternative to dynamic dispatch.
The Power of Enums
Recall that a Rust enum defines a type that can hold a value corresponding to one of several predefined variants.28 Crucially, the definition lists all possible variants, forming a closed set. The compiler knows every possible state the enum can represent.
match
as the Dispatcher
The match
expression is the key to using enums for dispatch. When matching on an enum value, the compiler requires all variants to be handled (or a wildcard _
arm used). Because it knows all possible variants, the compiler can generate highly optimized code to branch to the correct block based on the enum’s current variant.4 This often translates to efficient machine code, potentially using jump tables for enums with many variants or simple comparisons for fewer variants.4
How it Achieves Static Dispatch
This combination of a closed set of variants and exhaustive match
effectively provides static dispatch. When a method is implemented on the enum itself (often containing a match
internally), calling that method triggers the match
. The match
directly executes the code associated with the current variant. There’s no need for a vtable lookup or an indirect function call to figure out what code to run; the branching logic is determined at compile time based on the enum definition.6
Performance
This enum-based dispatch approach typically offers performance close to, or sometimes even better than, monomorphized static dispatch, and generally significantly faster than dynamic dispatch.4
- No Vtable Overhead: It avoids the runtime vtable lookup inherent in
dyn Trait
calls.6 - Optimization Potential: Within each
match
arm, the compiler knows the concrete type of the data associated with that variant. This allows it to inline function calls and perform other optimizations on the code within that specific arm.13 - Memory Efficiency: Enums can often be stored directly on the stack or inline within collections like
Vec
. AVec<MyEnum>
stores the enum values contiguously (each taking the size of the largest variant plus a tag), potentially leading to better cache locality compared to aVec<Box<dyn MyTrait>>
, which stores heap-allocated fat pointers.4 This avoids per-item heap allocation and pointer indirection.
The fundamental requirement for enum dispatch is the “closed world” assumption: all possible types that need to be handled polymorphically must be known upfront and listed as variants in the enum definition.27 If the system needs to be extensible, allowing users or other parts of the codebase to add new types later without modifying the original enum, then dyn Trait
is the appropriate choice due to its inherent openness.21 Adding a new variant to an enum requires modifying the enum definition itself and updating all match
expressions that handle it throughout the codebase.
While the concept is straightforward, manually implementing a trait for an enum by writing match
statements that delegate to the variants’ methods for every single trait method can become repetitive boilerplate. Recognizing this pattern, the Rust ecosystem offers crates like enum_dispatch
that use procedural macros to automatically generate these match
implementations, simplifying the process.4
Illustrative Example: Handling Different Shapes
Let’s solidify the concept with a common example: calculating the area of different geometric shapes. Suppose our application only needs to deal with Circles and Rectangles.
Scenario: We need a way to store different shapes and calculate their areas polymorphically.
Enum Definition (Shape
Enum):
First, define the structs for our concrete shapes and an enum to represent the closed set of shapes we support.
Rust
// Concrete shape types
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
// Enum representing the closed set of known shapes
enum Shape {
Circle(Circle),
Rectangle(Rectangle),
}
This Shape
enum explicitly declares that the only shapes our system currently understands are Circle
and Rectangle
.
Implementation (impl Shape
)
Next, implement the area
method directly on the Shape
enum. The match
expression handles the dispatch.
Rust
impl Shape {
fn area(&self) -> f64 {
match self {
// If it's a Circle variant, use the circle area formula
Shape::Circle(c) => std::f64::consts::PI * c.radius * c.radius,
// If it's a Rectangle variant, use the rectangle area formula
Shape::Rectangle(r) => r.width * r.height,
}
}
}
Inside the match
, each arm receives the specific data associated with the variant (c
of type Circle
or r
of type Rectangle
) and applies the correct logic.
Code Usage:
Now, we can create instances of Shape
and work with them polymorphically.
Rust
fn main() {
let shapes = vec!;
let mut total_area = 0.0;
for shape in shapes.iter() {
let current_area = shape.area(); // Static dispatch via match happens inside.area()
println!("Shape area: {}", current_area);
total_area += current_area;
}
println!("Total area of all shapes: {}", total_area);
}
When shape.area()
is called, the match
inside the impl Shape
block determines which calculation to run based on the variant currently held by shape
. Notice that we can store Shape
instances directly in a Vec<Shape>
.
Contrast this with a dynamic dispatch approach using Vec<Box<dyn ShapeTrait>>
. The enum approach with Vec<Shape>
stores the actual enum values (which contain either a Circle
or Rectangle
) potentially contiguously in the vector’s memory buffer. This avoids a heap allocation for each shape (assuming the shapes themselves don’t contain heap allocations) and stores regular-sized enum values rather than fat pointers in the vector. This can lead to better cache performance as the data is more likely to be localized.4 The Vec<Box<dyn ShapeTrait>>
approach, conversely, would store fat pointers in the vector, each pointing to a heap-allocated shape object, introducing allocation overhead and potential memory fragmentation.4
Showdown: Enum Dispatch vs. dyn Trait
Let’s summarize the key differences and trade-offs between these two powerful polymorphism techniques in Rust.
- Enum +
match
: Provides static dispatch for a closed, compile-time known set of types. dyn Trait
: Provides dynamic dispatch for an open, potentially runtime-determined set of types implementing a trait.
Direct Comparison – The Trade-offs
Choosing between them involves weighing several factors:
- Runtime Performance: Enum dispatch generally wins. The
match
compiles to efficient branching logic (often direct jumps or comparisons), avoids vtable lookups, and allows the compiler to inline and optimize the code within each match arm.4 Dynamic dispatch incurs vtable lookup costs and, more significantly, hinders compiler optimizations like inlining.1 - Flexibility (Adding Types):
dyn Trait
is the clear winner for flexibility. New types implementing the trait can be introduced anywhere (even in downstream crates) without modifying the code that uses&dyn Trait
orBox<dyn Trait>
.26 Enum dispatch is inflexible; adding a new shape (e.g.,Triangle
) requires modifying theShape
enum definition and updating every singlematch
statement that handlesShape
variants.27 This is its main limitation. - Memory Usage: Enum dispatch often has lower memory overhead. Enums can be stack-allocated or stored directly inline in collections like
Vec<Shape>
, avoiding per-item heap allocations and using regular values/pointers.6dyn Trait
frequently involvesBox
(heap allocation) and always uses fat pointers when referenced, doubling the pointer size.4 - Cache Locality: Storing enums directly in a
Vec
can lead to better cache locality compared to storingBox<dyn Trait>
pointers, which might point to disparate locations on the heap.4 - Code Size: Pure static dispatch via generics can cause significant code bloat through monomorphization.3 Dynamic dispatch avoids this specific type of bloat.3 Enum dispatch sits somewhere in between; it avoids monomorphization bloat across types but the
match
statements themselves contribute to code size. - Compile Time: Heavy monomorphization can increase compile times.9 Dynamic dispatch can sometimes lead to faster compile times as less code needs to be generated and optimized.4 Enum dispatch compile times are generally reasonable, likely faster than heavy generics but potentially slower than pure dynamic dispatch depending on the complexity and number of match arms.
- Abstraction Level:
dyn Trait
provides a strong abstraction boundary. Code using a&dyn MyTrait
only knows about theMyTrait
interface and is completely unaware of the underlying concrete type. Enum dispatch, viamatch
, inherently requires the code performing the dispatch to know about all possible variants, leading to tighter coupling between the dispatch logic and the specific set of types.
Comparison Table
This table summarizes the key characteristics:
Feature | Enum + match (Static Dispatch) | dyn Trait (Dynamic Dispatch) |
Dispatch Time | Compile-time (via match logic) |
Runtime (via vtable lookup) |
Runtime Speed | Generally Faster (direct calls, inlining possible) | Generally Slower (indirect call, inhibits inlining) |
Flexibility | Low (Closed set, requires modifying enum/match) | High (Open set, new types easily added) |
Memory Overhead | Lower (Stack possible, normal pointers/values) | Higher (Box common, fat pointers) |
Cache Locality | Potentially Better (inline storage in Vec<Enum> ) |
Potentially Worse (Vec<Box<dyn Trait>> ) |
Code Size | Less bloat than generics, match adds code |
Can reduce bloat vs generics, single implementation |
Compile Time | Moderate | Can be faster than heavy generics |
Primary Use Case | Known, fixed set of types; performance critical | Unknown/extensible types; flexibility needed |
Conclusion
Rust provides multiple ways to achieve polymorphism, each suited to different scenarios. While dyn Trait
offers unparalleled flexibility for handling types unknown at compile time, it comes with runtime costs associated with vtable lookups and inhibited compiler optimizations.
When dealing with a closed set of types known at compile time, using an enum combined with match
provides a highly performant and type-safe alternative. This pattern leverages Rust’s powerful enum and pattern matching features to implement static dispatch, avoiding vtable overhead and enabling crucial compiler optimizations like inlining within match arms.
Guidance for Choosing:
- Choose Enum +
match
when:- The set of types to be handled is fixed and known at compile time (e.g., internal message types, finite state machine states, specific API request types).
- Runtime performance is a primary concern.
- Extensibility by downstream users or dynamic loading of types is not required.
- Choose
dyn Trait
when:- You need to handle types that are not known at compile time (e.g., plugins, user-defined types).
- Flexibility and extensibility are paramount.
- You need heterogeneous collections where the concrete types vary unpredictably.
- The runtime performance overhead is acceptable for the required flexibility.
- Choose Generics (
impl Trait
/<T: Trait>
) when:- You need static dispatch, but the set of types is not fixed or known upfront (common in library APIs).
- You want maximum performance through monomorphization and are willing to accept potential compile time and binary size increases.
Consider enum dispatch a valuable technique in your Rust toolkit. Before reaching for Box<dyn Trait>
, evaluate if the set of types you need to handle is actually fixed. If it is, the enum dispatch pattern might offer a simpler, more performant solution that plays well with Rust’s strengths.