To frame this generically, you have a bunch of type-erased objects (elements, events), and you need to dispatch to handlers for specific pairs of two types (e.g. ElementA
and MouseUpEvent
). This is a double dispatch problem, and there are two ways to deal with it in C++.
Double Dispatch Using The Visitor Pattern
This is the "classic" technique described in the Design Patterns book. Basically, the base event (node) has a virtual method that dispatches to a downcasted dispatch method in a element (visitor). The visitor methods are virtual methods which dispatch to the right element. You can read about it in the book, on Wikipedia, or a billion online tutorials. I can't make a better tutorial here, except to add that you can reduce the amount of boilerplate a lot by using variadic templates:
// Visitor
template <typename... TNodes>
struct GenericVisitor;
template <typename TNode, typename... TNodes>
struct GenericVisitor<TNode, TNodes...> : public GenericVisitor<TNodes...> {
virtual bool Visit(const TNode&) { return false; }
};
template <>
struct GenericVisitor<> {
virtual ~GenericVisitor() = default;
};
// Node
template <typename TVisitor>
struct GenericBaseNode {
virtual bool Accept(TVisitor& visitor) const = 0;
};
template <typename TNode, typename TVisitor>
struct GenericNode : public GenericBaseNode<TVisitor> {
virtual bool Accept(TVisitor& visitor) const { return visitor.Visit(*static_cast<TNode*>(this)); }
};
After that, adding specifics for your setup can be done in just a few lines of code:
struct MouseEvent;
struct KeyEvent;
using Element = GenericVisitor<MouseEvent, KeyEvent>;
using Event = GenericBaseNode<Element>;
struct MouseEvent : public GenericNode<MouseEvent, Element> { };
struct KeyEvent : public GenericNode<KeyEvent, Element> { };
struct ElementA : public Element {
virtual bool Visit(const MouseEvent&) { return true; }
};
And to dispatch, just call the Accept
method of an event:
void DispatchEvent(Event& event, Element& element) {
event.Accept(element);
}
Double Dispatch Using Tagged Unions (std::variant)
Since C++17 (or C++11 with a single-header library), you can use a type-safe union called std::variant
, which can hold a fixed set of types in the same memory space, and dispatch between them. A global method called std::visit
can be used to dispatch a callback based on the contained type of one or more variants. This happens to be really handy for double-dispatch.
First, use a variant to hold an event:
struct MouseEvent { };
struct KeyEvent { };
using Event = std::variant<MouseEvent, KeyEvent>;
Then, a variant for each element, with methods to handle each event (use a base class to get rid of some boiler plate):
struct BaseElement {
template <typename TEvent>
bool OnEvent(const TEvent&) { return false; }
};
struct ElementA : public BaseElement {
using BaseElement::OnEvent;
bool OnEvent(const MouseEvent&) { return true; }
};
using Element = std::variant<ElementA>;
Then use std::visit
with a generic lambda to dispatch:
void DispatchEvent(Event& event, Element& element) {
std::visit([](auto&& el, auto&& ev) { return el.OnEvent(ev); }, element, event);
}
Ultimately, what this generates and optimizes to looks a bit like the event handler an old C GUI application:
struct Event { int id; union { MouseEvent AsMouseEvent; KeyEvent AsKeyEvent; }; };
struct Element { int id; union { ElementA AsElementA; }; };
switch (event.id) {
case MouseEvent:
switch(element.id) {
case ElementA:
return element.AsElementA.OnEvent(event.AsMouseEvent);
/*...*/
}
break;
/* ... */
}
Choosing
Neither method has a clear overall advantage performance-wise, so I recommend just using whichever one looks easier to use or maintain.
visitor pattern
- vtable size of visitors increases as types added
- can be used with C++98
- heterogeneous container must store dynamically allocated pointers to base type
- ... but no wasted object memory
std::variant
- amount of generated dispatch code increases as types added
- c++11 (via library, C++17 with std) or later
- can store heterogeneous members in contiguous memory
- ... but wasted memory if objects vary in size.
Demo of both: https://godbolt.org/z/MGp2No