Introduction to Rust generics [2/2]: Trait Objects (Static vs Dynamic dispatch)

Introduction to Rust generics:

This post is an excerpt from my book Black Hat Rust

Now you may be wondering: How to create a collection that can contain different concrete types that satisfy a given trait? For example:

trait UsbModule {
    // ...
}

struct UsbCamera {
     // ...
}

impl UsbModule for UsbCamera {
    // ..
}

impl UsbCamera {
    // ...
}

struct UsbMicrophone{
     // ...
}

impl UsbModule for UsbMicrophone {
    // ..
}

impl UsbMicrophone {
    // ...
}

let peripheral_devices: Vec<UsbModule> = vec![
    UsbCamera::new(),
    UsbMicrophone::new(),
];

Unfortunately, this is not as simple in Rust. As the modules may have a different size in memory, the compiler doesn't allow us to create such a collection. All the elements of the vector don't have the same shape.

Traits objects solve precisely this problem: when you want to use different concrete types (of varying shape) adhering to a contract (the trait), at runtime.

Instead of using the objects directly, we are going to use pointers to the objects in our collection. This time, the compiler will accept our code, as every pointer has the same size.

How to do this in practice? We will see below when adding modules to our scanner.

Static vs Dynamic dispatch

So, what is the technical difference between a generic parameter and a trait object?

When you use a generic parameter (here for the process function):
ch_04/snippets/dispatch/src/statik.rs

trait Processor {
    fn compute(&self, x: i64, y: i64) -> i64;
}

struct Risc {}

impl Processor for Risc {
    fn compute(&self, x: i64, y: i64) -> i64 {
        x + y
    }
}

struct Cisc {}

impl Processor for Cisc {
    fn compute(&self, x: i64, y: i64) -> i64 {
        x * y
    }
}

fn process<P: Processor>(processor: &P, x: i64) {
    let result = processor.compute(x, 42);
    println!("{}", result);
}

pub fn main() {
    let processor1 = Cisc {};
    let processor2 = Risc {};

    process(&processor1, 1);
    process(&processor2, 2);
}

The compiler generates a specialized version for each type you call the function with and then replaces the call sites with calls to these specialized functions.

This is known as monomorphization.

For example the code above is roughly equivalent to:

fn process_Risc(processor: &Risc, x: i64) {
    let result = processor.compute(x, 42);
    println!("{}", result);
}

fn process_Cisc(processor: &Cisc, x: i64) {
    let result = processor.compute(x, 42);
    println!("{}", result);
}

It's the same thing as if you were implementing these functions yourself. This is known as static dispatch. The type selection is made statically at compile time. It provides the best runtime performance.

On the other hand, when you use a trait object:
ch_04/snippets/dispatch/src/dynamic.rs

trait Processor {
    fn compute(&self, x: i64, y: i64) -> i64;
}

struct Risc {}

impl Processor for Risc {
    fn compute(&self, x: i64, y: i64) -> i64 {
        x + y
    }
}

struct Cisc {}

impl Processor for Cisc {
    fn compute(&self, x: i64, y: i64) -> i64 {
        x * y
    }
}

fn process(processor: &dyn Processor, x: i64) {
    let result = processor.compute(x, 42);
    println!("{}", result);
}

pub fn main() {
    let processors: Vec<Box<dyn Processor>> = vec![
        Box::new(Cisc {}),
        Box::new(Risc {}),
    ];

    for processor in processors {
        process(&*processor, 1);
    }
}

The compiler will generate only 1 process function. It's at runtime that your program will detect which kind of Processor is the processor variable and thus which compute method to call. This is known dynamic dispatch. The type selection is made dynamically at runtime.

The syntax for trait objects &dyn Processor may appear a little bit heavy, especially when coming from less verbose languages. I personally love it! In one look, we can see that the function accepts a trait object, thanks to dyn Processor.

The reference & is required because Rust needs to know the exact size for each variable.

As the structures implementing the Processor trait may vary in size, the only solution is then to pass a reference. It could also have been a (smart) pointer such as Box, Rc or Arc.

The point is that the processor variable needs to have a size known at compile time.

Note that in this specific example, we do &*processor because we first need to dereference the Box in order to pass the reference to the process function. This is the equivalent of process(&(*processor), 1).

When compiling dynamically dispatched functions, Rust will create under the hood what is called a vtable, and use this vtable at runtime to choose which function to call.

This post is an excerpt from my book Black Hat Rust

Some Closing Thoughts

Use static dispatch when you need absolute performance and trait objects when you need more flexibility or collections of objects sharing the same behavior.

1 email / week to learn how to (ab)use technology for fun & profit: Programming, Hacking & Entrepreneurship.
I hate spam even more than you do. I'll never share your email, and you can unsubscribe at any time.

Tags: hacking, programming, rust, tutorial

Want to learn Rust, Cryptography and Security? Get my book Black Hat Rust!