All posts

Rust Static and Dynamic Dispatch

A Java developer's guide to Rust compile-time and runtime polymorphism using generics, traits, static dispatch, and dyn Trait.

On this page

In Java, there are two broad use cases for type abstraction:

  • Compile-time polymorphism: A class or function works over different data types while preserving type safety. For example, ArrayList<User>, HashMap<String, Double>, Optional<User>. The compiler ensures that there is no type mismatch and prevents a variable of type ArrayList<User> from being assigned an ArrayList<String>.

  • Runtime polymorphism: The code uses the advertised capabilities of an interface but does not determine the concrete implementation. For example, a method accepts List<User> but the caller can pass ArrayList<User> or LinkedList<User>.

List<User> uses both ideas. User is compile-time generic type information. List is an interface, so the concrete implementation can still vary.

Compile-Time Polymorphism: Generics

Compile-time polymorphism in Rust is implemented using generics.

The basic syntax is:

fn first<T>(items: Vec<T>) -> Option<T> {
    items.into_iter().next()
}

T is a type parameter. The caller decides the concrete type:

let user = first(Vec::<User>::new());
let number = first(vec![1, 2, 3]);

The function is written once, but the compiler type-checks it for the concrete types used by the caller.

Most useful generic functions need the type to support some behavior.

For example, a max function needs to compare two values. But T can be any type, and not every type can be compared.

Rust solves this by adding a condition to the generic type. This condition is called a trait bound.

fn max<T: Ord>(left: T, right: T) -> T {
    if left >= right {
        left
    } else {
        right
    }
}

T: Ord is the trait bound. Read it as:

  • T can be any type
  • but it must implement the Ord trait
  • therefore the function is allowed to compare left and right

There is another syntax for the same idea:

fn print_debug(value: impl std::fmt::Debug) {
    println!("{:?}", value);
}

This is shorthand for:

fn print_debug<T: std::fmt::Debug>(value: T) {
    println!("{:?}", value);
}

impl Trait is used when the bound is simple. T: Trait is used when the same type parameter has to be mentioned more than once:

fn same_type<T: Ord>(left: T, right: T) -> T {
    if left >= right {
        left
    } else {
        right
    }
}

In the above example, left and right must have the same concrete type.

For longer bounds, Rust also supports a where clause:

fn process<T>(value: T)
where
    T: std::fmt::Debug + Clone,
{
    println!("{:?}", value.clone());
}

This is still compile-time polymorphism. The where clause only makes the signature easier to read.

Runtime Polymorphism: Trait Objects

Runtime polymorphism in Rust is implemented using trait objects.

First, define a trait:

trait QueueStore {
    fn enqueue(&self, payload: Vec<u8>);
}

Then use dyn Trait when the concrete implementation is not fixed in the function signature:

fn publish(store: &dyn QueueStore, payload: Vec<u8>) {
    store.enqueue(payload);
}

&dyn QueueStore means:

  • the function receives a reference to some type that implements QueueStore
  • the concrete type is not named in the function
  • method calls go through runtime dispatch

This is closer to Java interface usage:

void publish(QueueStore store, byte[] payload) {
    store.enqueue(payload);
}

This is runtime polymorphism because the concrete implementation can be chosen while the program is running.

The previous sections described the API-level choice:

  • T: Trait means compile-time polymorphism.
  • &dyn Trait means runtime polymorphism.

The Mechanism: Static Dispatch Versus Dynamic Dispatch

Rust also has names for the call mechanisms:

  • Static dispatch: the compiler knows the concrete type and generates or directly calls code for it.
  • Dynamic dispatch: the concrete type is hidden behind dyn Trait, and method calls are resolved through a runtime table.

Consider a generic function:

fn publish<T: QueueStore>(store: T, payload: Vec<u8>) {
    store.enqueue(payload);
}

The compiler knows the concrete T used by each caller. Rust can generate code for that concrete type.

Now consider a function that uses dyn Trait:

fn publish(store: &dyn QueueStore, payload: Vec<u8>) {
    store.enqueue(payload);
}

Here the function only knows that store implements QueueStore. The concrete implementation is hidden behind the trait object.

The terminology is:

ConceptRust term
Compiler generates concrete versionsMonomorphization
T: Trait or argument-position impl TraitStatic dispatch
dyn TraitDynamic dispatch / trait object
Java removes generic type parameters at runtimeType erasure

Rust dynamic dispatch is not usually called type erasure. There is a loose conceptual similarity because dyn Trait hides the concrete type. But in Rust, the standard terms are dynamic dispatch and trait object.

Why this matters in practice:

  • fn publish<T: QueueStore>(store: T) does not behave like Java interface dispatch.
  • Rust can optimize generic code using the concrete type.
  • Generic Rust APIs can be both abstract and fast.
  • Use dyn Trait only when runtime flexibility is required.

Reading Generic Function Signatures

This syntax means both arguments have the same concrete type:

fn larger<T: Ord>(left: T, right: T) -> T {
    if left >= right {
        left
    } else {
        right
    }
}

The function can be called with two integers or two strings:

let x = larger(10, 20);
let y = larger("a", "b");

But it cannot be called with one integer and one string. Both arguments are T, so both arguments must have the same concrete type.

impl Trait in argument position behaves differently when used more than once:

fn print_pair(left: impl std::fmt::Debug, right: impl std::fmt::Debug) {
    println!("{:?} {:?}", left, right);
}

This allows different concrete types:

print_pair(10, "hello");

That is because the above function is shorthand for:

fn print_pair<L: std::fmt::Debug, R: std::fmt::Debug>(left: L, right: R) {
    println!("{:?} {:?}", left, right);
}

Notes:

RequirementRust syntax
Both values must have the same concrete typefn f<T: Trait>(a: T, b: T)
Each value can have a different concrete typefn f(a: impl Trait, b: impl Trait)

Trait Bounds Are Promises to the Compiler

A generic type T can be any type.

That means the compiler cannot assume that T supports printing, comparison, cloning, hashing, or any other behavior.

This does not compile:

fn print_value<T>(value: T) {
    println!("{:?}", value);
}

The function says it accepts any T, but the body tries to debug-print T. Not every type can be debug-printed.

Rust needs the function signature to say that T supports debug printing:

fn print_value<T: std::fmt::Debug>(value: T) {
    println!("{:?}", value);
}

The T: std::fmt::Debug part is a trait bound. Read it as:

T can be any type, as long as it implements Debug.

The same rule applies to comparisons:

fn larger<T>(left: T, right: T) -> T {
    if left >= right {
        left
    } else {
        right
    }
}

This does not compile because not every T can be ordered. The function needs an Ord trait bound:

fn larger<T: Ord>(left: T, right: T) -> T {
    if left >= right {
        left
    } else {
        right
    }
}

As a Java developer, this is similar to needing T extends Comparable<T> before calling compareTo.

static <T extends Comparable<T>> T larger(T left, T right) {
    return left.compareTo(right) >= 0 ? left : right;
}

Notes:

  • T means the function accepts any type
  • T: Debug means the function accepts any type that can be debug printed
  • T: Ord means the function accepts any type that can be ordered

The bound is a promise to the compiler. Once the promise is in the function signature, the compiler allows the function body to use that behavior.

Summary

The main idea is that Rust separates two things that are often blended together in Java code:

  • Capabilities are described using traits.
  • Dispatch strategy is chosen by the syntax.

The dispatch model is:

Use caseRust syntaxDispatch
Generic code over typesT: TraitCompile time
Simple generic argumentimpl TraitCompile time
Runtime interface-style dispatch&dyn TraitRuntime

For a Java developer, the important shift is:

  • T: Trait is not Java-style interface dispatch. It is static dispatch.
  • impl Trait in function arguments is shorthand for the same compile-time model.
  • &dyn Trait is the Rust syntax closest to Java interface dispatch.
  • Use generics by default when the concrete type is known to the compiler.
  • Use dyn Trait when the concrete implementation must stay open at runtime.