A Heterogeneous Event Store
I recently stumbled upon a heterogeneous event collection which turned
out to pose an interesting design problem. We are using library code (we
can't change) that provides a templated Event
to which we can
register callbacks and which we can raise later to invoke the callbacks.
The interface looks like this:
/* Library code */
template <typename T> struct Event
{
// T is a callable object, like std::function
void Register(T&& callback) { /* Register a callback */ }
// Raises the event and forwards the arguments to the callbacks
template <typename ... Args>
void Raise(Args&& ... args) { /* Invoke all registered callbacks */ }
};
A stub implementation that validates client code is typed properly would be:
template <typename T> struct Event
{
// T is a callable object, like std::function
void Register(T&& callback)
{
/* Register a callback */
_callback = std::move(callback);
}
// Raises the event and forwards the arguments to the callbacks
template <typename ... Args>
void Raise(Args&& ... args)
{
/* Invoke all registered callbacks */
_callback(std::forward<Args>(args)...);
}
T _callback;
};
Note that unlike the real implementation, this only stores the last
registered event, but that is irrelevant for the purpose of this post.
I'm providing the code just to have something to compile against
(otherwise Raise
would happily swallow any combination of arguments
passed to it). In reality, a more complex implementation would maintain
a list of callbacks, but this is sufficient for framing the design
problem.
The event collection which was wrapping a set of library events looked like this:
using LaunchCallback = std::function<void()>;
using SaveCallback = std::function<void(const std::string&)>;
using ExitCallback = std::function<void()>;
class EventStore
{
public:
void OnLaunch(LaunchCallback&& callback)
{
m_launchEvent.Register(std::forward<LaunchCallback>(callback));
}
void OnSave(SaveCallback&& callback)
{
m_saveEvent.Register(std::forward<SaveCallback>(callback));
}
void OnExit(ExitCallback&& callback)
{
m_exitEvent.Register(std::forward<ExitCallback>(callback));
}
void RaiseLaunch()
{
m_launchEvent.Raise();
}
void RaiseSave(const std::string& fileName)
{
m_saveEvent.Raise(fileName);
}
void RaiseExit()
{
m_exitEvent.Raise();
}
private:
Event<LaunchCallback> m_launchEvent;
Event<SaveCallback> m_saveEvent;
Event<ExitCallback> m_exitEvent;
};
Sample usage of the EventStore
:
EventStore store;
// Register a couple of callbacks
store.OnLaunch([]() { /* Do stuff */ });
store.OnSave([](const auto& arg) { /* Do stuff */ });
// Raise an event
store.RaiseSave("Some file name");
Looking at EventStore
, it's obvious that there is a lot of repetition
involved: hooking up a new event involves aliasing a new callback,
adding a new member to the class, and adding the corresponding
registration and Raise
member functions which end up being copy/pastes
of the other ones. There must be a better way!
An initial idea would be to use some sort of associative container
(hopefully something better than an
unordered_map),
but there is an interesting complication due to the fact that some of
the various events are actually of different types.
Event<std::function<void()>>
has a different type than
Event<std::function<void(const string&)>>
. There are potential
workarounds to explore, like standardizing on a single type and
requiring clients to, for example, only use callbacks that do not take
any arguments. Another option would be to pass in some base object to
each event and let each callback re-interpret it. This takes us down the
wrong path though. We don't need to do any runtime lookup - the initial
code doesn't.
From the repetition in EventStore
, it should become apparent that we
need some form of templated Register
and Raise
that would work for
each type of event we care about. A quick sketch of our function
signatures should look something like this:
template </* ??? */>
void Register(/* ??? */)
{
// Register callback to the appropriate event
}
template </* ??? */, typename ... Args>
void Raise(Args&& ... args)
{
// Raise the appropriate event, forwarding args to it
}
It is also clear that we need a way to store all of the events we need
in our class. Since they are of heterogeneous types, we can't store
them in a map or equivalent, but we don't need to. std::tuple
was
build exactly for this:
std::tuple<Event<LaunchCallback>,
Event<SaveCallback>,
Event<ExitCallback>> m_events;
Now the only remaining question is how to templatize our two member functions to enable a lookup in the tuple. One approach would be to use an enum:
enum class EventType : size_t
{
LaunchEvent = 0,
SaveEvent,
ExitEvent,
};
Given this enum, we can templatize on its values:
class EventStore
{
public:
template <EventType eventType, typename T>
void Register(T&& callback)
{
std::get<static_cast<size_t>(eventType)>(m_events).Register(std::forward<T>(callback));
}
template <EventType eventType, typename ... Args>
void Raise(Args&& ... args)
{
std::get<static_cast<size_t>(eventType)>(m_events).Raise(std::forward<Args>(args)...);
}
private:
std::tuple<Event<LaunchCallback>,
Event<SaveCallback>,
Event<ExitCallback>> m_events;
};
Callers can use this new implementation like this:
EventStore store;
// Register a couple of callbacks
store.Register<EventType::LaunchEvent>([]() { /* Do stuff */ });
store.Register<EventType::SaveEvent>([](const auto& arg) { /* Do stuff */ });
// Raise an event
store.Raise<EventType::SaveEvent>("Some file name");
With this implementation, we preserve the ability to have polymorphic
events but only need to implement the Register
and Raise
functions.
Adding a new event type now only requires aliasing the callback, adding
an enum member, and extending our member tuple by adding the new Event
to it.
The only drawback of this approach is the fact that we need to manually
keep the enum and the tuple in sync. This is not too bad, because if we
try to call std::get
with a number higher than the size of the tuple,
we get a compile time error. If we accidentally swap two events, if they
are of incompatible types (for example Event<LaunchCallback>
and
Event<SaveCallback>
, as one expects callbacks of type
std::function<void()>
and the other expects
std::function<void(const std::string&)>
), we get a compile-time error
because Register
and Raise
calls would fail to compile (attempting
to pass in callback/arguments of incompatible types). If we accidentally
swap two events of the same type, (Event<LaunchCallback>
and
Event<ExitCallback>
, since both LaunchCallback
and ExitCallback
are aliased to the same std::function<void()>
), runtime behavior is
equivalent, it just makes reading the code confusing. Now we end up
storing launch callbacks inside what we called Event<ExitCallback>
and
vice-versa. Runtime is not affected, as we would also raise
Event<ExitCallback>
by calling Raise<EventType::LaunchEvent>
, but
it's not ideal. We could drop the aliases altogether and simply have:
std::tuple<Event<std::function<void()>>,
Event<std::function<void(const string&)>>,
Event<std::function<void()>> m_events;
This solves the above issues but is not very readable. There are other
options, like picking different names for the aliases - instead of
naming the event, have them name the type of callback. Either way,
effectively what we are doing is a mapping from an enum into a set of
Event
types. We can actually push more information to the type system
and get rid of the need to do this mapping. We do that by making sure
our events are always of different types, even if the callback
signatures are the same. One way of achieving this is wrapping Event
and defining different types for each of our events:
template <typename T> struct EventWrapper
{
Event<std::function<T>> m_event;
};
struct LaunchEvent : EventWrapper<void()> { };
struct SaveEvent : EventWrapper<void(const std::string&)> { };
struct ExitEvent : EventWrapper<void()> { };
Note this is type information used only by the compiler and doesn't
bring any runtime overhead to our code. Inheritance is used here just so
we don't have to repeat declaring m_event
, we could have just as well
declared each struct independently. Now we can update the member tuple
to store an event of each of these types:
std::tuple<LaunchEvent, SaveEvent, ExitEvent> m_events;
Since they are of different types, we no longer need an enum to index
into the tuple, we can do it by type (note std::get
indexed by type
requires that the tuple contains distinct types, which is not the case
for Event<LaunchCallback>
and Event<ExitCallback>
, but it is for
LaunchEvent
and ExitEvent
):
template <typename T, typename Callback>
void Register(Callback&& callback)
{
std::get<T>(m_events).m_event.Register(std::forward<Callback>(callback));
}
template <typename T, typename ... Args>
void Raise(Args&& ... args)
{
std::get<T>(m_events).m_event.Raise(std::forward<Args>(args)...);
}
The Callback
template argument in Register
can be deduced once T
is specified. The full implementation is:
template <typename T> struct EventWrapper
{
Event<std::function<T>> m_event;
};
struct LaunchEvent : EventWrapper<void()> { };
struct SaveEvent : EventWrapper<void(const std::string&)> { };
struct ExitEvent : EventWrapper<void()> { };
class EventStore
{
public:
template <typename T, typename Callback>
void Register(Callback&& callback)
{
std::get<T>(m_events).m_event.Register(std::forward<Callback>(callback));
}
template <typename T, typename ... Args>
void Raise(Args&& ... args)
{
std::get<T>(m_events).m_event.Raise(std::forward<Args>(args)...);
}
private:
std::tuple<LaunchEvent, SaveEvent, ExitEvent> m_events;
};
Callers can use it like this:
EventStore store;
// Register a couple of callbacks
store.Register<LaunchEvent>([]() { /* Do stuff */ });
store.Register<SaveEvent>([](const auto& arg) { /* Do stuff */ });
// Raise an event
store.Raise<SaveEvent>("Some file name");
In this case, adding a new event requires declaring a new struct and adding it to the tuple. Since we are retrieving the event by its type, no mapping is involved.