rustarchitecturedynamic-dispatchentity-component-systemmonomorphism

How can I use trait objects and monomorphism together


I run into this same problem with a different flavor over and over and I'm not sure entirely how to describe it, so I'll simply give an example and explain how I'm stuck.

I'm working on an ECS system to push my understanding of the language. I would like this system to be able to support multiple storage types, including those defined by people using the library. So I made a minimal trait of what a storage has to do.

pub trait Storage {
    fn create_entity(&mut self) -> Entity;
    fn destroy_entity(&mut self, entity: Entity);
    fn add_component<C: Component>(&mut self, entity: Entity, component: C);
    fn remove_component<C: Component>(&mut self, entity: Entity);
}

This trait is not object safe, so the project uses monomorphism everywhere to take in different storages. This was fine until I wanted to add Commands. Commands are effectively a delayed execution of one of these storage methods. So I created them as such:

pub enum SystemCommand {
    CreateEntity,
    DestroyEntity(Entity),
    AddComponent(Entity, Box<dyn Component>),
    RemoveComponent(Entity, TypeId),
}

The problem comes from the AddComponent. I need to store the component being added, but the only way to do that is with dynamic dispatch. But to store it in my storage, I need the concrete type. This is non-negotiable since the performance in ECS come from iterating over contiguous memory. But commands NEED to be storable in a collection, and thus need to be either an enum or dynamic dispatch. But the dynamic dispatch route is also impossible, because it would need to take in a storage, making it object unsafe. For example

pub trait Command {
    fn execute(&self, &mut impl Storage);
}

I looked into how Bevvy does it, but they simply have a fixed storage type. They don't allow alternatives to be swapped in. But this goes against my principle of extensible code.

I'm pretty stuck on how to achieve my goals here. How can I store deferred commands for storage, while maintaining the flexibility of being able to swap out storage types.


Solution

  • It is not possible to perform static dispatch while doing dynamic dispatch, i.e., calling a generic function using a vtable. The same is true in other languages, such as C++. It cannot be technically implemented. This is a "no" for your first two questions.

    If you need to use homogeneous lists for the commands, one option is to encode all types into the command trait. This requires to switch the storage type at compile time rather than runtime, if this is an option.

    trait Command<S: Storage> {
        fn execute(&self, storage: &S);
    }
    
    struct AddComponent<C: Component>(C);
    
    impl<C: Component, S: Storage> Command<S> for AddComponent<C> {
        fn execute(&self, _storage: &S) {}
    }
    
    // ...
    
    let commands: Vec<Box<dyn Command<MyStorage>>> = vec![
        Box::new(AddComponent(MyComponentA)),
        Box::new(AddComponent(MyComponentB)),
    ];
    
    let storage = MyStorage;
    for cmd in commands {
        cmd.execute(&storage);
    }
    

    Here is a full example: playground