Skip to content

Latest commit

 

History

History
 
 

1_6_dispatch

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

Step 1.6: Static and dynamic dispatch

Static and dynamic dispatches are important concepts to understand how your code is compiled and works in runtime, and how you can solve certain day-to-day coding problems (related to polymorphism).

Static dispatch (also called "early binding") happens only at compile time. The compiler generates separate code for each concrete type that is used. In Rust static dispatch is a default way for polymorphism and is introduced simply by generics (parametric polymorphism): MyType<T, S, F>.

Dynamic dispatch (sometimes called "late binding") happens at runtime. The concrete used type is erased at compile time, so compiler doesn't know it, therefore generates vtable which dispatches call at runtime and comes with a performance penalty. In Rust dynamic dispatch is introduced via trait objects: &dyn MyTrait, Box<dyn MyTrait>.

You have to use dynamic dispatch in situations where type erasure is required. If the problem can be solved with a static dispatch then you'd better to do so to avoid performance penalties. The most common example when you cannot use static dispatch and have to go with dynamic dispatch are heterogeneous collections (where each item is potentially a different concrete type, but each one implements MyTrait).

For better understanding static and dynamic dispatches purpose, design, limitations and use cases, read through the following articles:

Object safety

The other reason to go with static dispatch is that except performance penalties, trait objects have the other major downside: not all traits can be used for creating trait objects. A trait needs to meet special object safety requirements:

  • The trait cannot require Self: Sized.
  • Method references the Self type in its arguments or return type.
  • Method has generic type parameters.
  • Method has no receiver.
  • The trait cannot contain associated constants.
  • The trait cannot use Self as a type parameter in the supertrait listing.

This can lead to quite tricky and non-obvious situations when writing code.

For better understanding object safety purpose, design and limitations, read through the following articles:

Dynamic-to-static optimization for closed types set

In situations where you need to deal with different types, but all possible types form a closed set (you know all the used types), dynamic dispatch can be replaced with a static dispatch in a price of some enum-based boilerplate.

For example the following dynamically dispatched code:

trait SayHello {
    fn say_hello(&self);
}

struct English;
impl SayHello for English {
    fn say_hello(&self) {
        println!("Hello!")
    }
}

struct Spanish;
impl SayHello for Spanish {
    fn say_hello(&self) {
        println!("Hola!")
    }
}

// We have to use trait object here to contain different types.
let greetings: Vec<Box<dyn SayHello>> = vec![
    Box::new(English),
    Box::new(Spanish),
];

Can be refactored in the following way (as far as we know that only English and Spanish types will be used):

trait SayHello {
    fn say_hello(&self);
}

struct English;
impl SayHello for English {
    fn say_hello(&self) {
        println!("Hello!")
    }
}

struct Spanish;
impl SayHello for Spanish {
    fn say_hello(&self) {
        println!("Hola!")
    }
}

enum Language {
    English(English),
    Spanish(Spanish),
}
impl SayHello for Language {
    fn say_hello(&self) {
        match self {
            Language::English(l) => l.say_hello(),
            Language::Spanish(l) => l.say_hello(),
        }
    }
}

// We contain different types without using trait objects.
let greetings: Vec<Language> = vec![English, Spanish];

There is also a handy enum_dispatch crate, which generates this boilerplate automatically in some cases. It has illustrative benchmarks about performance gains of using enum for dispatching.

Reducing code bloat optimization

Static dispatch with type parameters has a downside of generating rather a lot of code (for each type), bloating binary size and potentially pessimizing execution cache usage. However, often generics aren’t really needed for speed, but for ergonomics.

The canonical solution of this problem is to factor out an inner method that contains all of the code minus the generic conversions, and leave the outer method as a shell. For example:

pub fn this<I: Into<String>>(i: I) -> usize {
    // do something really complicated with `i.into()`
    // potentially spanning multiple pages of code
}

becomes

#[inline]
pub fn this<I: Into<String>>(i: I) -> usize {
    _this_inner(i.into())
}
fn _this_inner(i: String) -> usize {
    // same code as above without the conversion
}

This ensures only the conversion gets monomorphized, leading to leaner code and compile-time performance wins.

There is a handy momo crate, which generates this boilerplate automatically in some cases. Read through its explanation article:

Task

Estimated time: 1 day

Given the following Storage abstraction and User entity:

trait Storage<K, V> {
    fn set(&mut self, key: K, val: V);
    fn get(&self, key: &K) -> Option<&V>;
    fn remove(&mut self, key: &K) -> Option<V>;
}

struct User {
    id: u64,
    email: Cow<'static, str>,
    activated: bool,
}

Implement UserRepository type with injectable Storage implementation, which can get, add, update and remove User in the injected Storage. Make two different implementations: one should use dynamic dispatch for Storage injecting, and the other one should use static dispatch. Prove your implementation correctness with tests.

Questions

After completing everything above, you should be able to answer (and understand why) the following questions:

  • What is dispatch? When a function call represents a dispatch and when not?
  • How does static dispatch work?
  • How does dynamic dispatch work? Why is it required? Which limitations does it have in Rust? Why does it have them?
  • When dynamic dispatch can be replaced with static dispatch? When not? What are the trade-offs?
  • How can we reduce the size of compiler-generated code when using static dispatch?