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 dispatchDynamic dispatch
Common syntaxT: Trait, impl Traitdyn Trait
Method resolutionCompile timeRuntime via vtable
MonomorphizationGenerally yesNot at the trait-object call boundary
Inlining opportunitiesUsually greaterOften more limited
Code sizeCan grow with specializationsCan reduce duplication
Different concrete types in one collectionRequires another abstractionSupported through trait objects
Heap allocationNot inherently requiredNot 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.

← Back to part 1


Further Reading