Crafting dynamically dispatched Event Listeners in Rust

While working on the Leafy Engine I came across the problem of implementing an event system that would enable me to do the following:

I was trying to find some useful information about this topic online, but was quite disappointed to find out that there wasn't really anything helpful. This is why I decided to showcase the solution to this problem that I eventually came up with and tell you a bit about the little but anoying quirks that you will come across during this process. It is totally possible that there is a nicer way to accomplish this out there that I have not yet seen, but I still think this will be helpful to some people.

This article will focus on the broad priciple of using dynamic dispatch in this scenario and not implementation details to my engine. This way, I hope to make this as widely applicable and helpful as possible to most people.

DISCLAIMER:
There are arguments to be made against using this type of architecture in the first place. For example, most hyper-generalized systems solve the wrong kind of problem and only avoid quirks people using Rust will inevitably run into at some point. The reason I am still doing this is because I was specifically interested in how one might solve a problem like this without resorting to unsafe Rust. The project I was engaged in at the time of writing this was a pure learning experience for me. I think the existence of this article is still a net positive.

The Naive Approach

The first thing that probably comes to mind is storing the event listeners in a HashMap based on the event type, and then iterate over all the stored listeners every time an event gets triggered. This however will not allow for an arbitrary number of different events and listeners that are unknown at the time of implementation of the event system. If you don't need this generic functionality, you might not need all of this. Espacially when you have a background in a language like C or C++, you might think of using type erasure and storing pointers to the event listeners in the hash table. You would then need some kind of runtime type information to correctly cast the pointers to their respective listener type. You could also resort to some meta-programming, but that is really a pain in the butt when using Rust.

Luckily, there already is something in the standard library that seems to be the perfect fit for this task: the std::any module. It provides the Any trait, which can be used to get a TypeId of an enum or struct and downcast to a type that was originally used to create the Any trait object. Downcasting this way checks the TypeId's and only allows for valid casts. Trait objects are using the dyn keyword and require being stored in a container like a Box as they don't have a size known at compile time. More information on this in the Docs.

// this is how you might use the Any trait
fn main() {
    let value: i32 = 1;
    let boxed: Box<dyn Any> = Box::new(value);

    assert_ne!(value.type_id(), boxed.type_id()); // this will yield the TypeId of the container, not the value
    assert_eq!(value.type_id(), (&*boxed).type_id()); // you have to do this to get the &dyn Any

    let converted = *boxed.downcast_ref::<i32>().unwrap(); // the downcasting works because Box<T> implements Deref<T>
    assert_eq!(value, converted);
}

Doing this in Rust however without using unsafe Rust (we are not using unsafe Rust in this article) is not trivial and also still leaves some problems unresolved:
First, all of this introduces a whole lot of potential lifetime issues and you want the listeners to be borrowed mutably when calling their method that is called on an event trigger. Second, you dont know the specific types of the listeners. That means that we will have to use trait objects and cast to an intermediate level in the trait hierarchy. This is probably the biggest problem, as at the time of me writing this article trait upcasting is still an unstable feature, and even if it was stable, we could not use it, because we have a generic type parameter associated with the trait object (the event type) and we can not store that in a map generically.

This leads us to the following: We want to store trait objects dynamically in regards to the event type that they are listeners for. We also want to do that without knowing the specific type of the listener and casting to the correct type generically.

RefCell Hell

People are typically advised to not excessively rely on using something like RefCell, because you are essentially avoiding the rust borrowing rules and only crash at runtime, if they are actually violated. Especially people who are new to Rust and not used to the borrow checker might fall into that pit quickly. There are some cases though, where your software architecture requires its use or heavily asks for it - and then its fine to use. The only rule of thumb I want to give you is to always question twice, whether or not you really need one. It's quite similar to the use of dyn Any in that regard.

In our case, we will not get around using an Rc<RefCell>. We need to store references to the event listeners inside the event system and also be able to borrow them mutably on demand. Fortunately, Rc is already a smart pointer that allows us to store trait objects. We can then use weak references to them in order to not keep objects artificially alive.

The Any Problem

As already shown in the code snippet earlier, the downcasting only works if the TypeId's match the type we want to cast to. We want to cast to a trait object that is not of type Any, but specific to the event listener. If we also want to use the downcasting logic of Any, that requires for the listener object to be first cast to our trait object and then to another trait object of type Any. This is necessary to get the TypeId hashes to match correctly and to insert the vtable pointer into the trait object. That means that we have to nest two trait objects which means we have to use two different nested smart pointers and live with the double pointer indirection when accessing the underlying listener. We will probably not have a crazy amount of listeners, so the added performance hit is probably not a big deal.

You might think: well, why can't i just use something like RefMut::map for the trait casting and save one of the pointers? The reason for that is that in the end you will end up not being able to cast to a trait object because it is not Sized. I ended up spending quite some time figuring out a way around this issue. I will save you this time and get straight to the solution.

Putting it all together

The base of our event system is the storage of out event listeners. Until now I didn't mention the function events. For this we can just use funcition pointers with the correct argument event type. To use the same logic with the Any trait in this case, we can just wrap the pointer inside a struct. The keys of both HashMaps are the TypeId's of the events that we want to listen for.

// system managing the events
struct EventSystem {
    listeners: HashMap<TypeId, Vec<Box<dyn Any>>>,
    functions: HashMap<TypeId, Vec<Box<dyn Any>>>,
}

We will also need the trait we already dicussed and the wrapper for the funtion pointers.

// trait every event listener has to implement
trait EventListener<T: Any> {
    fn on_event(&mut self, event: &T);
}

// wrapper for the function pointer
struct EventFunction<T: Any> {
    f: fn(&T),
}

Now, to add new event listeners we first need to have a struct that implements the trait stored inside an Rc<RefCell>. We can then add them to our system and trigger events the following way:

impl EventSystem {
    // adds an event listener struct to the system
    fn add_listener<T: Any>(&mut self, handler: &Rc<RefCell<impl EventListener<T> + 'static>>) {
        let listeners = self.listeners.entry(TypeId::of::<T>()).or_default();
        listeners.push(Box::new(Rc::downgrade(handler) as Weak<RefCell<dyn EventListener<T>>>));
    }

    // adds an event function to the system
    fn add_function<T: Any>(&mut self, function: fn(&T)) {
        let wrapper = EventFunction { f: modifier };
        let functions = self.functions.entry(TypeId::of::<T>()).or_default();
        functions.push(Box::new(wrapper));
    }

    // trigger an event and call all relevant functions/listeners
    fn trigger<T: Any>(&self, event: T) {
        if let Some(handlers) = self.listeners.get(&TypeId::of::<T>()) {
            for handler in handlers {
                let casted_handler = handler
                    .downcast_ref::<Weak<RefCell<dyn EventObserver<T>>>>()
                    .unwrap();
                if let Some(handler_rc) = casted_handler.upgrade() {
                    let mut handler_ref = handler_rc.borrow_mut();
                    handler_ref.on_event(&event);
                }
            }
        }
        if let Some(modifiers) = self.modifiers.get(&TypeId::of::<T>()) {
            for modifier in modifiers {
                let casted = modifier.downcast_ref::<EventFunction<T>>().unwrap();
                (casted.f)(&event);
            }
        }
    }
}

This is just an outline of the basic principle and not very specific. You probably want to extend this system or mold it according to your own needs in your project.
I hope this was helpful to you and saved you some time trying to figure that out yourself. I also hope I was able to provide some insight into the way trait objects and casting between them works in Rust.