I discussed macro-based C++ type erasure in my latest article. Some people told me that macros are evil. We had a long debate on that subject and I agree that folks should stay far from macros. The discussion was nice and had a very positive impact. It made me think how we can avoid them, and I think I have found a very good way to do it!
For those who wants, here is the GitHub project Erased. It is not clean yet. It will be cleaned when we will advance in the series of article :).
Objective
The goal of this article is to be able to write something close to:
struct Draw {
static void invoker(const auto &self, std::ostream &stream) {
self.draw(stream);
}
// not necessary, but makes the client code easier to write
void draw(this const auto &erased, auto &stream) {
erased.invoke(Draw{}, stream);
}
};
using Drawable = erased::erased<Draw>;
struct Circle {
void draw(std::ostream &stream) const { stream << "Circle\n"; }
};
int main() {
Drawable drawable = Circle{};
drawable.draw(std::cout);
return 0;
}
The interface is inspired by AnyAny. You have actions (here Draw), and you can feed erased
by one or several actions. Here I gave only Draw. You can give several if needed.
The this const auto
is a C++23 feature named deducing this. Here, the type of the erased
variable is Drawable
or erased::erased<Draw>
.
## C++ type erasure without macro
We are going to see, again, how we can implement C++ type erasure . Here is the plan.
- Develop a
MethodTrait
which- retrieve the signature thanks to
invoker
function - compute the pointer to function
- Create a templated invoker
- retrieve the signature thanks to
- Store the pointer to functions
- Call the good pointer to function with a given action
## MethodTrait
The main difficulty to retrieve the signature, is that the invoker()
function is templated. Indeed, self
in the Draw
action is the concrete type (Circle
in the example). Hence, we introduced the erased_type_t
tag.
namespace erased {
struct erased_type_t {};
This tag is supposed to replace the concrete type. Therefore, we can deduce the full signature by injecting the erased_type_t
as template argument of invoker()
function. As a result, we can implement it.
template <typename Method, typename Signature> struct MethodTraitImpl;
template <typename Method, typename ReturnType, typename ErasedType,
typename... Args>
struct MethodTraitImpl<Method, ReturnType (*)(ErasedType &, Args...)> {
using first_argument = std::conditional_t<std::is_const_v<ErasedType>,
const std::any &, std::any &>;
using function_pointer = ReturnType (*)(first_argument, Args...);
template <typename T> static auto createInvoker() {
return +[](first_argument value, Args... args) {
return Method::invoker(*std::any_cast<T>(&value),
std::forward<Args>(args)...);
};
}
};
template <typename Method>
using MethodTrait =
MethodTraitImpl<Method, decltype(&Method::template invoker<erased_type_t>)>;
template <typename Method>
using MethodPtr = typename MethodTrait<Method>::function_pointer;
The MethodTraitImpl
retrieve, thanks to template specialization, the ReturnType
and the constness of the first argument. The first_argument
type is either a std::any&
or a const std::any&
. Indeed, it is the wrapper object containing the concrete object. We can use any_cast
to retrieve the concrete object. We call the static invoker
function from the Method
and forward to it all the args.
MethodPtr
is an helper to retrieve the according function_pointer
.
decltype(&Method::template invoker<erased_type_t>)
is the wizardry. We just specify to invoker
function the first template argument. Since we are within a decltype()
expression, the definition of invoker
is not inspected, then we retrieve the type of it, and the specialization decomposes it properly.
Note: That works only because invoker
has one and only one template parameter.
Erased type
The erased type needs to be inherited from the actions. So that, we can call the draw
function directly for example.
erased
will be designed like that
- Inheritance of all the actions objects
- Two different storage:
- storage of the concrete type
- storage of the function pointers.
- Construct the object
- invoke a given method
template <typename... Methods> class erased : public Methods... {
private:
std::any m_value;
std::tuple<MethodPtr<Methods>...> m_pointers{};
It is easy, we continue to use our std::any
object to store the concrete object. We then use our MethodPtr
trait to transform Methods
in a list of pointer to function.
The constructor will construct the std::any
and will call the createInvoker
function.
template <typename T>
erased(T x)
: m_value(std::move(x)),
m_pointers{MethodTrait<Methods>::template createInvoker<T>()...} {}
Invoke is a bit more complex. It takes a Method as first argument, computes at compile time which function to call, and forward the arguments into it.
template <typename T, typename... List> constexpr int index_in_list() {
std::array array = {std::is_same_v<T, List>...};
for (int i = 0; i < array.size(); ++i)
if (array[i])
return i;
return -1;
}
template <typename Method, typename... Args>
decltype(auto) invoke(Method, Args &&...args) const {
return std::get<index_in_list<Method, Methods...>()>(m_pointers)(
m_value, std::forward<Args>(args)...);
}
template <typename Method, typename... Args>
decltype(auto) invoke(Method, Args &&...args) {
return std::get<index_in_list<Method, Methods...>()>(m_pointers)(
m_value, std::forward<Args>(args)...);
}
Conclusion
We succeeded to find a low boilerplate approach for C++ type erasure. If you want to play with the code, you can click here.
If you don’t have access to C++23, you can use CRTP
.
Here is what I planned to write during the next weeks:
- Write an article to optimize the current code to avoid that all instances have all pointers to all functions. Basically, we will introduce the concept of custom vtable.
- Write an article about small object optimization.
- Write an article to get rid of
std::any
by using what we saw on small object optimization.
Thanks!
Leave a Reply