tl;dr:
- Completely abstracting away the signature of the function AND still calling it in a type-safe way is impossible in C++
- A type-based event system could be a good alternative
1. Why it's impossible to do what you're asking for
1.1 How Type-Erasure works
Type-erasure is fundamentally based on polymorphism. By defining a set of methods that all objects we want to store have in common (the interface) we don't need to know the actual type we're dealing with.
There is no way to do type-erasure without involving polymorphism.
For example, a very crude implementation of std::function could look like this:
template<class RetVal, class... Args>
class function {
public:
template<class U>
function(U u) : ptr(new impl<U>(u)) {}
~function() { delete ptr; }
RetVal operator()(Args... args) {
return ptr->call(args...);
}
private:
struct base {
virtual ~base() = default;
virtual RetVal call(Args... args) = 0;
};
template<class T>
struct impl : base {
impl(T t): t(t) {}
RetVal call(Args... args) override {
return t(args...);
}
private:
T t;
};
base* ptr;
};
template<class RetVal, class... Args>
class function<RetVal(Args...)> : public function<RetVal, Args...> {};
godbolt example
This is how std::function accomplishes to store any function object that is compatible with it's signature - it declares an interface (base) that will be used by all function objects (impl).
The interface only consists of 2 functions in this case:
- The destructor (we need to know how to properly cleanup the function object)
- The
call() function (for invoking the actual function)
Sidenote 1: A real std::function implementation would need a couple more interface functions, e.g. for copying / moving the callable
Sidenote 2: Your existing implementation has a small bug: struct base MUST have a virtual destructor, otherwise the destructor of struct callable would never be called, resulting in undefined behaviour.
1.2 How your callable would need to work
What you want is an object that completely erases both the function object AND the parameters that you pass.
But what should your interface then look like?
struct base {
virtual ~base() = default;
virtual ??? call(???); // how should this work?
};
This is the underlying problem you're facing - it's impossible to define an interface for your callable - because you don't know what the arguments are gonna be.
This is what @Yakk - Adam Nevraumont implied with "non-uniform" objects - there is no definition of call() that can handle all potential function types.
1.3 Options
So at that point you basically have two options:
- Don't erase the function type (like @Yakk - Adam Nevraumont suggested)
- Sacrifice compile-time type safety and maintainability by creating an interface that can deal with arbitrary function types
The latter option is what your code currently uses - either the function parameters match or your code has undefined behaviour.
A few other ways to implement it that don't rely on undefined behaviour could be:
- Add an interface function for each possible argument combination
struct base {
/* ... */
// All possible ways a `callable` could potentially be invoked
virtual void call(int val0) { throw std::exception("invalid call"); };
virtual void call(std::string val0) { throw std::exception("invalid call"); };
virtual void call(const char* val0) { throw std::exception("invalid call"); };
virtual void call(int val0, std::string val1) { throw std::exception("invalid call"); };
virtual void call(int val0, const char* val1) { throw std::exception("invalid call"); };
// etc...
}
// then implement the ones that are sensible
struct callable<std::string> : public base {
/* ... */
void call(std::string val0) override { /* ... */ }
void call(const char* val0) override { /* ... */ }
}
This obviously gets out of hand rather quickly.
- "Accept anything" interface
struct base {
/* ... */
virtual void call(std::any* arr, int length);
};
// then implement the ones that are sensible
struct callable<std::string> : public base {
/* ... */
void call(std::any* arr, int length) override {
if(length != 1) throw new std::exception("invalid arg count");
// will throw if first argument is not a std::string
std::string& value = std::any_cast<std::string&>(arr[0]);
/* ... */
}
};
A bit better, but still looses compile-time type safety.
1.4 Conclusion
- Compile-time type-safety with type-erasure is only possible if there is an uniform interface for all possible objects.
- It is technically possible to type-erase non-uniform objects, but if you do that you'll loose compile-time type-safety (and need to do those checks at runtime instead)
2. Another Approach: Type-Based Event System
I'd like to propose a different way to handle the events that allows you to have arbitrary events without having to hard-code them into your Events class.
2.1 Basic Functionality
The main idea of this implementation is to have a class for each event you'd want to have that contains the parameters for the given event, e.g.:
struct AppClosingEvent {
const std::string message;
const int exitCode;
};
struct BananaPeeledEvent {
const std::shared_ptr<Banana> banana;
const std::shared_ptr<Person> peeler;
};
// etc...
This would then allow you to use the type of the event struct as a key for your event listeners.
A very simple implementation of this event system could look like this: (ignoring unregistration for now)
class EventBus {
private:
using EventMap = std::multimap<std::type_index, std::function<void(void*)>>;
// Adds an event listener for a specific event
template<class EvtCls, class Callable>
requires std::is_invocable_v<Callable, EvtCls&>
inline void Register(Callable&& callable) {
callbacks.emplace(
typeid(EvtCls),
[cb = std::forward<Callable>(callable)](void* evt) {
cb(*static_cast<EvtCls*>(evt));
}
);
}
// Broadcasts the given event to all registered event listeners
template<class EvtCls>
inline void Broadcast(EvtCls& evt) {
auto [first, last] = callbacks.equal_range(typeid(EvtCls));
for(auto it = first; it != last; ++it)
(it->second)(&evt);
}
private:
EventMap callbacks;
};
Register() takes a callable object that needs to be invocable with the given event type. Then it type-erases the callable so we can store it as a std::function<void(void*>
Broadcast(evt) looks up all event listeners that are registered based on the type of the event object and calls them.
Example Usage would look like this:
EventBus bus;
bus.Register<AppClosingEvent>([](AppClosingEvent& evt) {
std::cout << "App is closing! Message: " << evt.message << std::endl;
});
bus.Register<BananaPeeledEvent>([](BananaPeeledEvent& evt) {
// TODO: Handle banana peeling
});
AppClosingEvent evt{"Shutting down", 0};
bus.Broadcast(evt);
By using the type of the event as the key both Register() and Broadcast() are completely type-safe - it's impossible to register a function with incompatible function arguments.
Additionally the EventBus class doesn't need to know anything about the events it'll handle - adding a new event is as simple as defining a new class with the members you need for your event.
2.2 Adding the ability to unregister an event listener
I chose to use a multimap in this case because they guarantee to not invalidate iterators, unless the element the iterator points to itself gets removed from the multimap - which allows us to use a multimap iterator as the registration token for the event handler.
Full implementation: godbolt example
/*
EventBus - allows you to register listeners for arbitrary events via `.Register()`
and then later invoke all registered listeners for an event type with `.Broadcast()`.
Events are passed as lvalues, to allow event handlers to interact with the event, if required.
*/
class EventBus {
private:
using EventMap = std::multimap<std::type_index, std::function<void(void*)>>;
public:
/*
Represents a registered event handler on the EventBus.
Works a lot like std::unique_ptr (it is movable but not copyable)
Will automatically unregister the associated event handler on destruction.
You can call `.disconnect()` to unregister the event handler manually.
*/
class Connection {
private:
friend class EventBus;
// Internal constructor used by EventBus::Register
inline Connection(EventBus& bus, EventMap::iterator it) : bus(&bus), it(it) { }
public:
inline Connection() : bus(nullptr), it() {}
// not copyable
inline Connection(Connection const&) = delete;
inline Connection& operator=(Connection const&) = delete;
// but movable
inline Connection(Connection&& other)
: bus(other.bus), it(other.it) {
other.detach();
}
inline Connection& operator=(Connection&& other) {
if(this != &other) {
disconnect();
bus = other.bus;
it = other.it;
other.detach();
}
return *this;
}
inline ~Connection() {
disconnect();
}
// Allows to manually unregister the associated event handler
inline void disconnect() {
if(bus) {
bus->callbacks.erase(it);
detach();
}
}
// Releases the associated event handler without unregistering
// Warning: After calling this method it becomes impossible to unregister
// the associated event handler.
inline void detach() {
bus = nullptr;
it = {};
}
private:
EventBus* bus;
EventMap::iterator it;
};
// Adds an event listener for a specific event
template<class EvtCls, class Callable>
requires std::is_invocable_v<Callable, EvtCls&>
inline Connection Register(Callable&& callable) {
auto it = callbacks.emplace(
typeid(EvtCls),
[cb = std::forward<Callable>(callable)](void* evt) {
cb(*static_cast<EvtCls*>(evt));
}
);
return { *this, it };
}
// Broadcasts the given event to all registered event listeners
template<class EvtCls>
inline void Broadcast(EvtCls& evt) {
auto [first, last] = callbacks.equal_range(typeid(EvtCls));
for(auto it = first; it != last;)
(it++)->second(&evt);
}
private:
EventMap callbacks;
};
With this you can easily register listeners and unregister them later (e.g. if the class they're bound to gets destructed)
Example:
struct DispenseNachosEvent {};
struct DispenseCheeseEvent {};
class NachoMachine {
public:
NachoMachine(EventBus& bus) {
// register using std::bind
nachoEvent = bus.Register<DispenseNachosEvent>(
std::bind(
&NachoMachine::OnDispenseNachos,
this,
std::placeholders::_1
)
);
// register with lambda
cheeseEvent = bus.Register<DispenseCheeseEvent>(
[&](DispenseCheeseEvent& evt) {
OnDispenseCheese(evt);
}
);
}
// Default destructor will automatically
// disconnect both event listeners
private:
void OnDispenseNachos(DispenseNachosEvent&) {
std::cout << "Dispensing Nachos..." << std::endl;
}
void OnDispenseCheese(DispenseCheeseEvent&) {
std::cout << "Dispensing Cheese..." << std::endl;
}
private:
EventBus::Connection nachoEvent;
EventBus::Connection cheeseEvent;
};
2.3 Other benefits
- If you want you can also allow the event handlers to modify the event object - e.g. cancel it - which allows you to return state to the piece of code that called
Broadcast()
Example:
struct CancelableExampleEvent {
inline void Cancel() { isCancelled = true; }
inline bool IsCancelled() { return isCancelled; }
CancelableExampleEvent(std::string message) : message(message) {}
const std::string message;
private:
bool isCancelled = false;
};
// Usage:
CancelableExampleEvent evt;
bus.Broadcast(evt);
if(!evt.IsCancelled()) {
// TODO: Do something
}
- Event Handlers can remove themselves - this is usually tricky to implement due to iterators being invalidated, but with multimaps it's rather easy to implement:
template<class EvtCls>
inline void Broadcast(EvtCls& evt) {
auto [first, last] = callbacks.equal_range(typeid(EvtCls));
for(auto it = first; it != last;)
(it++)->second(&evt);
}
By incrementing it before calling the function we make sure that it remains valid, even if the event handler chooses to unregister itself as part of its callback.
e.g. this would work:
EventBus::Connection con;
con = bus.Register<SomeEvent>([&con](SomeEvent&){
std::cout << "Received event once!" << std::endl;
con.disconnect();
});
2.4 Try it online!
Here's a godbolt that contains the entire code of this post to try it out.