Static Dispatch

// #Rust

Rust is my first exposure to choosing what form of dispatch to use static or dynamic. It comes up when generalizing some code with polymorphism. The goal is to write code once which doesn’t care how its dependencies are implemented, as long as they satisfy an interface. I did a previous deep dive from a “bottom up” perspective on how these dispatch implementations effect memory usage. Now I’ll take the opposite approach to see what rust patterns a developer can tap into to effectively use them.

Just at face value, I like the promises of static dispatch. I always prefer things to be checked at compile time vs. runtime, and it sounds like generally static is more performant (although for a lot of use cases it most likely doesn’t matter, like if I/O work is dominating). But funny enough, I actually am quite used to dynamic dispatch coding patterns from the golang world. Golang made the call to always use dynamic dispatch, the developer doesn’t get to choose (or worry) about the difference. And this enables the magic behind golang’s “accept interfaces, produce structs” mantra. A type can consume any implementation of an interface and use it to do its task. Changing the underlying implementation type doesn’t change anything about the consuming type. This requires dynamic dispatch.

Keep It Static

We can see though if something like this can be achieved in rust without dynamic dispatch. Let’s say a module exposes a type Protocol which is generic across the AsyncRead trait, so Protocol<T: AsyncRead>. This is pretty standard, essentially the Protocol can do its job with anything that implements the async I/O interface. The module’s consumer knows they are only going to use a TCP socket implementation which implements the AsyncRead trait. Very cool, the developer can quickly write up some code which connects to a TCP socket based on some given runtime IP address and pass that to the Protocol. This is an ideal scenario for static dispatch. The rust compiler can analyze the usage and stamp out the Protocol<TCP> type. Notice that is a distinct type, unlike in golang where it would just be Protocol no matter what it is working on. Here it doesn’t matter to the caller since they are only using TCP sockets.

What if the caller takes some runtime input which could change what type of AsyncRead implementation it passes to Protocol? If they get a raw IP address, use the TCP implementation, but if they get an onion address, use a SOCKS5 wrapper. There are now two possible protocol types, Protocol<TCP> and Protocol<SOCKS5>. And the twist is that we don’t know which variant until runtime. This worries the compiler, a lot, because it doesn’t know the exact size and memory layout of the type that will be used. It needs this information to generate its ultra-performant, zero-cost abstraction code, like how much memory should be allocated on the stack for a function call.

There is some golang-like syntax which may give you hope that it is easy to get the best of both worlds. What if a trait is defined which describes the protocol, ProtocolBehavior, and it’s implemented across Protocol<T>. Then you might think you could return just an implementation of that trait, and it starts to look a little golang-y with just one return type.

fn create_protocol(runtime_flag: Flag) -> impl ProtocolBehavior {
   // Factory functions.
}

While this hide’s the underlying type, it won’t compile since the compiler doesn’t know which type will be returned.

However, this won’t compile. The compiler will see that different types could be returned based on the state of the flag and give up. Unlike golang, rust just forces you to be explicit about this dynamic behavior.

fn create_protocol(runtime_flag: Flag) -> Box<dyn ProtocolBehavior> {
   // Factory functions.
}

Explicit dynamic dispatch for protocol implementation types.

OK, so I could just give up and use the dynamic dispatch pattern, but what if I absolutely wanted static dispatch? If the runtime_flag enables 100’s of different AsyncRead implementations, it’s probably easiest to just roll with dynamic dispatch, let the runtime do the busywork of wiring things up. But what if there are only 2 options like the TCP and SOCKS5 versions above? Perhaps then I can take on the busywork and let things get optimized at compile time.

The main issue is we want to expose a single interface for the caller, some sort of ProtocolBehavior, but also give enough information to the compiler about the memory usage. What if we introduce a new type, and enum, called ProtcolType which enumerates all the possible variants of Protoco<T> within our context.

enum ProtocolType {
    Tcp(Protocol<TCP>),
    Socks5(Protocol<SOCKS5>),
}

fn create_protocol(runtime_flag: Flag) -> ProtocolType {
  // Factory functions.
}

Variants enumerated for the compiler.

No heap allocation, direct function calls, and this is enough information for the compiler to allocate memory. Although I’ll admit I don’t know the specifics on how well the compiler optimizes this case, but it does happily compile it. So this checks the box for the compiler, but it still leaves a bit to be desired for caller ergonomics and developer upkeep.

The caller is now interacting with the protocol through the ProtocolType enum. How should they use the shared behavior of the variants? The could write some match arms themselves for each variant, but probably easier to just stick these on the enum itself.

impl ProtocolType {
    fn send(&mut self, data: &[u8]) -> io::Result<()> {
        match self {
            ProtocolType ::Tcp(p) => p.send(data),
            ProtocolType ::Socks5(p) => p.send(data),
        }
    }
    
    fn receive(&mut self) -> io::Result<Vec<u8>> {
        match self {
            ProtocolType ::Tcp(p) => p.receive(),
            ProtocolType ::Socks5(p) => p.receive(),
        }
    }
}

Manual dispatching.

I think it can be viewed as manual static dispatch, we are routing each call to the appropriate implantation. It looks very boilerplate-y because the top level function name and the variant versions all line up, which feels like maybe there should be some automatic way for the compiler to do this. But how would the compiler know? There isn’t a type system link between the ProtocolType enum and its variants, other than that they are variants. We would need some sort of “trait delegation” feature to tie them together.

Some macros could maybe help with the boilerplate, but given that this should really only be used when there are small amount of variants, maybe clearest to just bite the bullet. I think there are still some patterns for helping ergonomics and upkeep. The exhaustive match statements will make sure a maintainer wires things up for a new variant. And what might be nice for a maintainer and a caller is a shared trait, like ProtocolBehavior from before. Let’s say it requires two methods to be implemented, send and receive, and they get definitions for all Protocol<T> types. The ProtocolType enum also implements ProtocolBehavior with the manual dispatching from above. This doesn’t change much for the boilerplate, but it does allow the caller to just operate on ProtocolBehavior now if they prefer (e.g. as a function argument or trait bound). This allows them to accept the enum dispatcher or specific variant itself. Plus a good spot to splice in a test mock.


```rust
fn process_data<P: ProtocolBehavior>(protocol: &mut P) {
    protocol.send(b"GET /data HTTP/1.1\r\n\r\n").unwrap();
    ...
}

Allow the caller to not care about the implementation.

Where To Draw The Line

OK, let’s say I’ve decided I can’t live in a dynamic world and will accept the boilerplate for static. Where should it live? What if the Protocol<T: AsyncRead> type is imported from a crate dependency. It probably doesn’t make too much sense for that maintainer to export a trait for the type as well. Plus don’t wanna run into any orphan rule complications. Best just to own as much as possible for the known context.

dependency-crate/
  |
  +-- Protocol<T: Read>

your-crate/
  |
  +-- trait ProtocolBehavior
  +-- enum ProtocolType
  +-- implementations (e.g impl<T: Read> ProtocolBehavior for Protocol<T>)

Where the line is drawn.

So in the end, we have our interface which we are working against. But there is quite a bit of boilerplate. Internally, we might end up with some App<ProtocolType> syntax, but now we are back to the “this is the only thing we are using” world.