This post proposes a way to rewrite B
as a templated class without inheritance, virtual methods, or variant types.
Basic idea: templated wrapper class
The fundamental idea is to replace A
or B
in the OP with a templated wrapper (personal term):
#include <concepts>
#include <utility>
#include <iostream>
// core implementation of wrapping (callbacks added later)
template<std::invocable A>
class ActionWithCallbacks {
public:
using Action = A;
ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
action(std::forward<decltype(a)>(a))
{
}
void operator()() {
action();
// TODO: invoke the callbacks.
}
private:
Action action;
};
// CTAD rule (makes construction less verbose).
template<std::invocable Action>
ActionWithCallbacks(Action) -> ActionWithCallbacks<Action>;
// Creation & usage
int main() {
// No need to precise the template parameter, thanks to the CTAD.
ActionWithCallbacks awc = []() { std::cout << "Action"; };
awc();
}
Live demo (godbolt)
All this class does (for now) is to encapsulate any std::invocable
object into a custom type. We can expose as much of the wrapped object's API as we want, with the option to add side effects.
Wrappers like that are quite common in the standard library:
std::unique_ptr
wraps a raw pointer. It changes its destructor and move semantics. It deletes its copy assign/constructor.
std::shared_ptr
wraps a raw pointer, and adds a pointer to a reference counter. It changes its destructor and move/copy semantics.
std::function
wraps a callable object, but for different reasons. The goal is to perform type erasure, a pattern which internally uses wrappers like this one.
Adding callbacks:
Like std::shared_ptr
adds a reference counter to the wrapped raw pointer, our type can easily add a container of callbacks:
template<std::invocable A>
class ActionWithCallbacks {
public:
using Action = A;
ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
action(std::forward<decltype(a)>(a))
{
}
void addCallback(std::convertible_to<CallbackType> auto&& newCb) {
callbacks.emplace_back(std::forward<decltype(newCb)>(newCb));
}
void operator()() {
action();
for (auto& cb : callbacks) {
cb();
}
}
private:
Action action;
std::vector<CallbackType> callbacks;
};
Rewriting foo()
If the only thing that foo
requires from its argument is using the action, I would personally keep it simple:
void foo(std::invocable auto& b) { b(); }
If it needs the full API of ActionWithCallbacks
, I would define a proper concept. There are 2 schools of thought:
// 1) duck-typing concept: accepts any type with the correct API.
template<typename T>
concept cActionWithCallbacks = requires (T t, CallbackType cb) {
t.addCallback(cb);
t();
};
// 2) type matching concept: accepts only specializations of ActionWithCallbacks
template<typename T>
concept cActionWithCallbacks = std::is_same_v<T,ActionWithCallbacks<typename T::Action>>;
// Anyway
void foo(cActionWithCallbacks auto& b) { b(); }
Full working demo
That's certainly a bit heavy on setup, but quite compact at the use point:
#include <concepts>
#include <type_traits>
#include <utility>
#include <vector>
#include <functional>
#include <iostream>
// Setup
using CallbackType = std::function<void()>;
template<std::invocable A>
class ActionWithCallbacks {
public:
using Action = A;
ActionWithCallbacks(std::convertible_to<Action> auto&& a) :
action(std::forward<decltype(a)>(a))
{
}
void addCallback(std::convertible_to<CallbackType> auto&& newCb) {
callbacks.emplace_back(std::forward<decltype(newCb)>(newCb));
}
void operator()() {
action();
for (auto& cb : callbacks) {
cb();
}
}
private:
Action action;
std::vector<CallbackType> callbacks;
};
template<std::invocable Action>
ActionWithCallbacks(Action) -> ActionWithCallbacks<Action>;
template<typename T>
concept cActionWithCallbacks = std::is_same_v<T,ActionWithCallbacks<typename T::Action>>;
// Usage
void foo(cActionWithCallbacks auto& f) {
std::cout << "foo() start\n";
f();
std::cout << "foo() end\n";
}
int main() {
int i = 0;
ActionWithCallbacks awc = [&i]() {
std::cout << "- Action: i = -1\n";
i = -1;
};
awc.addCallback([&i]() {
std::cout << "- Callback 1: i = i + 5\n";
i += 5;
});
awc.addCallback([&i]() {
std::cout << "- Callback 2: i = 3 * i\n";
i *= 3;
});
foo(awc);
std::cout << "main(): i == " << i << '\n';
}
Live demo
Warning: The current implementation of ActionWithCallbacks
can store the Action
by value, or by reference. Some might see it as a feature, some might see it as a horrible design decision causing dangling references and bugs. For the later group: static_assert(!std::is_reference_v<Action>);
or std::remove_reference_t
will help.