Dynamic Dispatch vs Static Dispatch in Rust

Understand the difference between static and dynamic dispatch, how monomorphization and vtables work, and when to choose generics or trait objects for polymorphic code.

Dynamic Dispatch vs Static Dispatch in Rust

When you call a method through a trait, the compiler needs to figure out which concrete implementation to execute. That decision process is called dispatch. Rust gives you two distinct dispatch mechanisms—static dispatch, resolved entirely at compile time, and dynamic dispatch, resolved at runtime. Each has a very different effect on performance, binary size, and the flexibility of your code.

Understanding the trade‑offs between them is what lets you write polymorphic code that is both safe and fast, without paying for runtime overhead when you don’t need it.

What Are Static and Dynamic Dispatch?

Static dispatch means the compiler knows the exact type at every call site before the program ever runs. It generates a dedicated copy of the function for each concrete type that uses it. This is the default behaviour of generics and impl Trait in Rust.

Dynamic dispatch means the specific type is not known until runtime. Instead of producing a separate function per type, the compiler creates a single function that works through a vtable—a table of function pointers. The concrete implementation is looked up in that table when the method is called. Trait objects (&dyn Trait, Box<dyn Trait>) use dynamic dispatch.

Dispatch is about function calls, not only traits:

Although the term comes up most often with trait methods, static vs. dynamic dispatch also applies to ordinary function calls. Any function where the concrete type is known at compile time uses static dispatch; only calls through dyn introduce dynamic dispatch.

How Static Dispatch Works

Generics and Trait Bounds

Consider a trait that defines a single method and two types that implement it:

trait Draw {
    fn draw(&self) -> String;
}
struct Circle {
    radius: f64,
}
struct Square {
    side: f64,
}
impl Draw for Circle {
    fn draw(&self) -> String {
        format!("Circle of radius {}", self.radius)
    }
}
impl Draw for Square {
    fn draw(&self) -> String {
        format!("Square of side {}", self.side)
    }
}

A generic function constrained by the trait uses static dispatch:

fn render<T: Draw>(item: &T) {
    println!("{}", item.draw());
}

The function signature says “accept anything that implements Draw,” but what the compiler does behind the scenes is far more concrete.

Monomorphization Explained

When you call render(&circle) and render(&square), Rust performs monomorphization. It creates a separate, specialized version of render for each concrete type—as if you had manually written:

fn render_for_circle(item: &Circle) {
    println!("{}", item.draw());
}
fn render_for_square(item: &Square) {
    println!("{}", item.draw());
}

The call item.draw() inside the generic function becomes a direct call to Circle::draw or Square::draw. There is no table lookup, no pointer indirection, and no extra runtime decision. The compiled code contains the address of the correct function hard‑wired into the call site.

Zero‑Cost Abstraction:

Monomorphization turns generic code into static function calls that are as efficient as if you wrote the separate implementations by hand. This is what Rust means by a “zero‑cost abstraction”—you pay nothing at runtime for the generics.

This transformation has a side effect: each unique combination of type parameters produces a new copy of the function’s machine code. If a generic function is used with many different types, the binary can grow noticeably larger. This is often called code bloat. For most applications the increase is negligible, but it is something to be aware of in constrained environments.

Impact on Performance and Binary Size

Static dispatch makes method calls trivially cheap. The CPU knows the exact jump target ahead of time, and the compiler can inline small functions directly into the caller, eliminating the call overhead entirely.

The trade‑off is binary size. A generic function used with ten different types produces ten copies of its body in the final executable. In practice, this rarely matters unless you have a very large number of distinct instantiations or you are building for a memory‑constrained embedded target.

You can influence the behaviour with #[inline] annotations. The compiler already inlines aggressively with optimizations enabled, but adding #[inline] to a small utility function in a library crate can signal to downstream crates that inlining is desirable.

How Dynamic Dispatch Works

Trait Objects and dyn Trait

When you want to handle multiple concrete types through a single interface without knowing the types at compile time, you use a trait object. The keyword dyn signals dynamic dispatch:

fn render(item: &dyn Draw) {
    println!("{}", item.draw());
}

Now the same function can accept both a Circle and a Square—or any other type implementing Draw—through the same compiled function. The compiler generates only one copy of render, not one per type.

let shapes: Vec<&dyn Draw> = vec![&circle, &square];
for shape in shapes {
    shape.draw(); // resolved at runtime
}

The concrete types have been erased; the vector only knows that each element implements Draw. The actual type information is carried alongside each element in a form that allows the correct draw to be called.

Fat Pointers and Vtables

A trait object is a fat pointer: it holds two ordinary pointers packed together.

  1. A data pointer that points to the actual value on the stack or heap.
  2. A vtable pointer that points to a table of function pointers—the vtable.

The vtable is generated by the compiler for each concrete type that implements the trait. It contains the addresses of the trait’s methods for that type, plus additional metadata like the destructor and alignment information.

So &dyn Draw for a Circle is actually (pointer to the Circle data, pointer to the vtable for <Circle as Draw>).

Runtime Method Lookup

When you call shape.draw(), the compiler emits code that:

  1. Reads the vtable pointer from the fat pointer.
  2. Loads the function pointer for draw from the vtable.
  3. Calls that function with the data pointer as self.

This is one level of indirection compared to a static call. The CPU must dereference the vtable pointer, then call through the function pointer. Inlining across the call boundary is impossible because the compiler doesn’t know which concrete function will be reached until runtime.

Memory Layout

A &dyn Draw on a 64‑bit system is usually 16 bytes: 8 bytes for the data pointer, 8 for the vtable pointer. In contrast, a plain &Circle is only 8 bytes. This extra width is the price for type erasure—the pointer itself carries the dispatch information.

If you need ownership, you use Box<dyn Draw>, which heap‑allocates the value and holds the fat pointer inside the Box. Other smart pointers like Arc<dyn Draw> work the same way, giving shared ownership with runtime dispatch.

When to Use Static Dispatch

Static dispatch is the default and generally the preferred choice. Reach for it when:

  • All concrete types are known at compile time.
  • You want the compiler to optimize aggressively (inlining, dead code elimination).
  • You don’t need to store different types together in the same collection.
  • Performance is critical and you have measured it to matter.

A typical static‑dispatch pattern uses generics and trait bounds:

fn report_area<T: Shape>(shape: &T) {
    println!("Area: {:.2}", shape.area());
}

The compiler creates a separate report_area for each T you call it with. There is no runtime overhead, and the call to area can be inlined.

When to Use Dynamic Dispatch

Dynamic dispatch becomes useful when the set of types is open or unknown at compile time, or when you need to mix different implementations in a single structure.

Common situations include:

  • Heterogeneous collections: a Vec<Box<dyn EventHandler>> that holds different handler types.
  • Returning different types from a function: a factory that might return a Circle or a Square behind a Box<dyn Shape>.
  • Plugin systems: types loaded dynamically or supplied by downstream crates.
  • Reducing binary size: when a generic function would be instantiated for hundreds of types, a single trait‑object version can save significant code size.
fn load_plugins() -> Vec<Box<dyn Plugin>> {
    vec![
        Box::new(AuthPlugin),
        Box::new(LoggingPlugin),
        Box::new(CachePlugin),
    ]
}

Dynamic dispatch trades a small amount of runtime overhead for the ability to treat unrelated types uniformly.

Comparing the Two Approaches Side by Side

The following example shows the same interface—a function that prints a greeting—implemented with both static and dynamic dispatch. The code is equivalent from the caller’s perspective, but the generated machine code is very different.

trait Greet {
    fn greet(&self) -> String;
}
struct Dog;
struct Cat;
impl Greet for Dog {
    fn greet(&self) -> String {
        "Woof!".to_string()
    }
}
impl Greet for Cat {
    fn greet(&self) -> String {
        "Meow!".to_string()
    }
}
fn say_hello<T: Greet>(animal: &T) {
    println!("{}", animal.greet());
}

The compiler generates separate say_hello functions for Dog and Cat. Each call is a direct jump to the correct method.

Both versions are valid. The static version will typically be faster for repeated calls, while the dynamic version keeps the binary smaller and allows mixing of types at runtime.

Performance Considerations

It is tempting to declare “static dispatch is fast, dynamic dispatch is slow,” but the reality is more nuanced.

  • Static dispatch enables inlining, which for very small functions (a few instructions) can eliminate the function call entirely. This can lead to significant speed‑ups in tight loops.
  • Dynamic dispatch incurs a double indirection (data pointer → vtable → method) and a function call that cannot be inlined. For work that is dominated by other costs—I/O, complex computation—this overhead is often irrelevant.
  • Large amounts of monomorphization can stress the instruction cache because the CPU must keep many slightly different copies of the same logic hot. In some rare cases, a single dynamic dispatch version can outperform a heavily monomorphized binary by reducing cache pressure.

Measure before you micro‑optimize:

Don’t rewrite working static dispatch code to dynamic dispatch just to “save binary size,” nor avoid trait objects by default because they are “slow.” Profile your program first. For most everyday code, the difference is imperceptible.

The Sized trait also plays a role. Types that are Sized (have a known size at compile time) can be used with generics easily. If you add where Self: Sized as a bound on a trait method, that method cannot be called through a trait object—it is only available in static dispatch contexts. This can be intentional: it prevents the method from ever paying the vtable overhead.

Common Mistakes and Misconceptions

1. Forgetting Object Safety

Not every trait can be used with dyn. If a trait contains a method that returns Self (without where Self: Sized), has generic type parameters, or uses associated types in certain ways, it is not object‑safe. Trying to create a Box<dyn NonObjectSafe> will produce a compiler error.

Compile Error:

Attempting to create a trait object from a non‑object‑safe trait results in an error like the trait \Foo` cannot be made into an object. Check your method signatures if you plan to use dyn`.

2. Assuming Static Dispatch Always Inlines

Monomorphization enables inlining but does not guarantee it. Complex functions with branches and loops may not be inlined regardless of dispatch method. Rely on the compiler’s heuristics and use #[inline] sparingly where profiling shows a benefit.

3. Using Box<dyn Trait> When an Enum Suffices

If you have a fixed, small set of types that you control, an enum with a match gives you static dispatch with no heap allocation or vtable. Consider:

enum Shape {
    Circle(f64),
    Square(f64),
}
impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle(r) => std::f64::consts::PI * r * r,
            Shape::Square(s) => s * s,
        }
    }
}

This is often simpler and faster than Vec<Box<dyn Shape>> when the set of variants is closed.

4. Overusing Dynamic Dispatch for “Flexibility”

Trait objects are not a universal default for polymorphism. They introduce a level of indirection, heap allocations (with Box), and loss of type information that can make code harder to debug. Use them when the design genuinely requires runtime‑determined types; otherwise, generics or enums are usually cleaner.

Summary

Static and dynamic dispatch are not about good versus bad—they are two tools for different jobs. Rust’s design lets you choose explicitly at every point: impl Trait or <T: Trait> for compile‑time resolution, dyn Trait for runtime flexibility.

The monomorphization model of static dispatch gives you zero‑cost generics, turning trait calls into direct function jumps and often inlining them away entirely. Dynamic dispatch, through fat pointers and vtables, lets you store and call different types through a uniform interface, paying a small runtime cost for that flexibility.

A Rust codebase that feels natural will lean heavily on static dispatch, pulling in trait objects only where the set of types is truly open or unknown. When you encounter a situation that seems to demand Box<dyn Trait>, first ask if an enum or a generic parameter could do the job. When the answer is genuinely “no,” dynamic dispatch is there, and it works well.