Pker.xyz

Runtime Static Dispatch via Rust's Sum Types

Static dispatch (also called early binding) and dynamic dispatch (late binding, runtime dispatch, virtual method call) are the ways to resolve member function (methods) bindings. In other words they are ways of choosing which implementation of a method/function to use.

In static dispatch, this is done statically (at compile time). In dynamic dispatch, the resolution happens at runtime. The former is often the default in compiled language. The latter is often used when doing some form of polymorphism, for example when multiple types have their own implementation of the "same" method (interfaces in many languages, traits in rust).

Dynamic Dispatch

Dynamic dispatch has the advantage of not requiring to know statically which concrete type (aka which implementation) will be used during the program execution, but the disadvantage that it requires some machinery, which often somewhat hidden from the programmer, to allow for such flexibilty. This can also incur a runtime performance cost, since the work required to resolve which method to call needs to happens during program execution.

This is a well supported feature of OOP languages: the method calls don't need to depend on the compile time type of a variable, but on it's runtime/dynamic one. This allows programmer to write functions that take generic or interface parameters that may be resolved during runtime.

In some languages (i.e Python) this is the default. In some others (rust, C++), dynamic dispatch is not the default and is opt-in. Both C++ and Rust use a virtual function table (vtable) to resolve the method to call at runtime. A vtable typically contains the mappings between a type and the address of the implementation of the function we wish to call (it's effectively an array of function pointers). It's simply an extra layer of indirection which allows doing this mapping at runtime: when the concrete type is resolved (at runtime) the vtable for this type can be accessed and the method to call can be resolved and called.

The index of a given method inside the virtual table needs to be known at compile time for the resolution to work correctly. One way to do this would be that for a given interface/trait that has 5 methods, the methods have a designed index/id. During compilation, if we call

pseudo-cpp
UnknownInstanceOfInterface.method1();

without knowing the concrete type of UnknownInstanceOfInterface at compile time, the compiler can replace this with

pseudo-cpp
UnknownInstanceOfInterface.vtable[IndexofMethod1]();

This works if there's a guarantee that all implementators of the interface store the same indexes for a the same methods.

In C++, dynamic dispatch is in play when a method is declared virtual. When that happens, instance of objects that have virtual methods have an extra hidden field which is a pointer to the vtable. This extra member affects the layout of the type. A typical call site might look something like:

text
object pointer -> in mem object contents -> vtable field (ptr to the vtable) -> vtable (ptr to the method) -> actual method

This is quite a bit of pointer chasing. They also use strategies such as name mangling to help support late binding, but that's outside of the scope of this post.

Rust

The cpp way is different from rust, where dynamic dispatch happens when using a trait object via the `dyn InsertTraitHere` keyword.

In rust generics are resolved using monomorphization, a fancy word that simply means that there is an implementation of a fn that is generated at compile time for every type combination that is used in the code in place of a generic. This allows static dispatch for generics (this is related but also slightly out of the scope of this post).

Dynamic dispatch is used with dynamic traits, for times when we don't know before runtime which implementator of the trait will be in play. In rust, a common idiom is to see the dyn Trait pattern inside a Box. This is because things need to be Sized (rust needs to know the on-stack size of the type at compile time) which it can't if it doesn't know what the type actually is, only that it implements some trait. An easy fix is to Box it:

pseudo-rust
Box<dyn Trait>

That way rustc is happy because the size of a Box pointer is known at compile time. The big contrast between rust and cpp is that in rust a trait object (dyn trait) is an entirely new type. Instead of injecting a new member to the instance of a type like in cpp, trait objects are fat pointers.

The fat pointer (dyn trait/trait object) contains two pointers: one to the vtable of the object, and one to the data of the object. The vtable ptr is therefore decoupled from the type itself. This also has a very powerful side effect: since the vtable is physically separated from the object's instance, the interfaces do not have to be known at compile time. The object layout is not modified so there is no absolute need to know what interface is implemented by what type at compile time. This allows implementing interfaces on multiple pre-existing types.

Enum Dispatch: Static and Dynamic?

There is no magic: if we want to delay resolving the mapping between a method and a type to runtime we need a layer of indirection. In the case of dynamic dispatch, this is done via vtable, which means that we are dealing with non-concrete types.

Using sum types, it's possible in Rust to resolve everything from the compilers point of view at compile time (static dispatch) while not knowing the concrete type. This is particularily useful when needing to store a collection of types where we do not know the concrete type at runtime, we would typically need to store a vector of boxed trait objects (Boxed dyn traits).

Instead, what we can do is store implementators of a trait inside an enum, have the collection hold instances of that enum and do the dispatching in that enum:

rust
// Sharing behavior (polymorphism), via traits.
trait Traitor {
    fn blah(&self) -> ();
}

// first implementator
struct A {}
impl Traitor for A {
    fn blah(&self) -> () {
        println!("Hello From A");
    }
}

// second implementator
struct B {}
impl Traitor for B {
    fn blah(&self) -> () {
        println!("Hello From B");
    }
}

// Enum used for the enum dispatch strategy
enum Traitors {
    AWrapper(A),
    BWrapper(B),
}
impl Traitor for Traitors {
    // Dispatches blah() to the "real" underlying type
    fn blah(&self) -> () {
        match self {
            self::Traitors::AWrapper(actual_a) => actual_a.blah(),
            self::Traitors::BWrapper(actual_b) => actual_b.blah(),
        }
    }
}
            

We can then dynamically create instances of the enum and have collections of them. Since rust enums are implemented as tagged unions, it stores the the value and the discriminant (which tells us the current variant). A downside to this approach is that the size of our enum's will have the overhead of the discriminant (a u8 if there are less than 256 variants) + the required padding for aligment. It can also create some bloat when the variants have widely different sizes (in our previous example, if A has lots of members and B is a very small struct, we pay a penality of sizeof(A)-sizeof(B) (rounded to aligment) when B is the variant.

Now the big advantage is that we do not have to deal with dynamic dispatch anymore: the type is selected at runtime but we know at compile time that we are calling enum.blah(); in the example above, and not instanceOfA.blah(); or instanceOfB.blah();. The dispatching happens inside enum.blah(). This is effectively a conversion from using dynamic dispatch to only relying on concrete types. This makes it much easier to debug and understand.

I'm not advocating for this technique at all times: when there are too many implementators of a trait or when the trait has too many methods it becomes hard to manage, and at this point it might be worth it to simply default to trait objects via dyn traits.

Another disadvantage of this strategy is that if you are designing a library you might want to give the users the possiblity to extend your type, possibly via the newtype pattern. In our case this is hard to do because everything goes through an enum, we could wrap that enum into another enum, but then we need to implement the trait methods/dispatching via that new enum again, creating more indirection. A sane way to allow for some flexibilty while keeping things simple is to have an enum variant that is specifically made for this. The following implementation showcases this strategy:

rust
// ...
// Enum used for the enum dispatch strategy
enum Traitors {
    AWrapper(A),
    BWrapper(B),
    // This allows extension: anything that implements Traitor is valid!
    Placeholder(Box<dyn Traitor>),
}
impl Traitor for Traitors {
    // Dispatches blah() to the "real" underlying type
    fn blah(&self) -> () {
        match self {
            self::Traitors::AWrapper(actual_a) => actual_a.blah(),
            self::Traitors::BWrapper(actual_b) => actual_b.blah(),
            // Consumer who extends the Traitors enum does not need to change anything!
            self::Traitors::Placeholder(who_knows) => who_knows.blah(), 
        }
    }
}
            

In this article we took a look at static vs dynamic dispatch, covered some virtual table basics and looked at what c++ does in the simple case. More importantly we investigated a way to leverage rust's tagged unions to avoid dynamic dispatch. This is a design decision with tradeoffs, just like all design decisons, and is not always better than relying on dynamic dispatch via trait objects.


Uploaded 08-10-2024