I presented a C++ type erasure implementation called CppDyn a few years ago. This implementation was criticized because it was a bit complex to use and did not feel natural.
Erased
Erased is the name of the new version of type erasure I made. It is not yet on my GitHub, but I hope it will be there in the following days. I’ll update the article when it’s done. Here is what Erased is supposed to look like.
struct Drawable {
void draw(std::ostream &) const;
};
ERASED_MAKE_DYN(Drawable, draw);
struct Circle {
void draw(std::ostream &stream) const {
stream << "Circle" << std::endl;
}
};
struct Rectangle {
void draw(std::ostream &stream) const {
stream << "Rectangle" << std::endl;
}
};
int main() {
dyn<Drawable> drawable = Circle();
drawable.draw(std::cout);
drawable = Rectangle();
drawable.draw(std::cout);
return 0;
}
What is Type erasure?
Type erasure is commonly called Duck Typing. The idea behind that concept is:
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
C++ implementation has a lot of advantages for this vtable-based polymorphism:
- You don’t need pointers anymore with polymorph objects.
- You don’t need inheritance at all, however, your type needs to verify one given concept.
The most known C++ type erased object is std::function
. Indeed, folks don’t need to inherit their lambda or other callable objects from it. You give it a callable object respecting the given prototype, and everything works as expected.
Common C++ type erasure implementation
Generally, and CppDyn did it too, type erasure in C++ is done with hidden inheritance. One object is called Concept
with the pure virtual behavior, and one template object is called Model<T>
inheriting from Concept
implements the behavior. These objects are hidden within the wrapper class and everything looks natural from client code.
Another C++ type erasure implementation: Erased
The idea behind Erased is to use a macro, not to define the whole prototype of the interface, but just to have the name. Erased creates itself the vtable and manages the construction and destruction of the given object. To make things simple, in this article, I’ll use std::any
to manage the object lifetime management and the vtable will be part of the type. These optimizations will be addressed in a later article. Before entering the macro world, let’s see what we need.
std::any
: for the construction and deletion of the object.- pointer to member function: to construct the vtable
- A system that computes return type and the arguments.
- An interface’s name
- One or several interface’s actions
Let’s begin!
Introducing dyn
We introduce the dyn
type.
template <typename T> class dyn;
Now, we specialize it with the interface’s name.
struct Drawable {
void draw(std::ostream &) const;
};
template <> class dyn<Drawable> {
using dyn_type = Drawable;
std::any m_object;
};
Computing return type and arguments
To compute return type and arguments, we use templates. For example, we can use template specialization.
template <typename T> struct drawInformation;
template <typename ReturnType, typename... Args>
struct drawInformation<ReturnType (dyn_type::*)(Args...) const> {
using pointer_function = ReturnType (*)(const std::any &, Args...);
template <typename U> static auto create_caller() {
return +[](const std::any &x, Args... xs) {
std::any_cast<U>(&x)->draw(static_cast<decltype(xs) &&>(xs)...);
};
}
};
template <typename ReturnType, typename... Args>
struct drawInformation<ReturnType (dyn_type::*)(Args...)> {
using pointer_function = ReturnType (*)(std::any &, Args...);
template <typename U> static auto create_caller() {
return +[](std::any &x, Args... xs) {
std::any_cast<U>(&x)->draw(static_cast<decltype(xs) &&>(xs)...);
};
}
};
This structure, thanks to a pointer to member function, retrieves the return type and the argument types. Furthermore, it provides a pointer_function
type and a create caller function. We added std::any
as first argument to not need to capture it. It allows the lambda to be converted to a pointer to function. create_caller
function is templated so that we know into which type to convert the std::any
. Consequently, we use this structure just after to have the correct pointer function based on the interface action: draw
in our case.
typename drawInformation<decltype(&dyn_type::draw)>::pointer_function m_draw;
All the magic lies in &dyn_type::draw
! Isn’t it beautiful?
We provide two template functions, one for const objects, and one for mutable objects, therefore, we can handle both cases easily.
template <typename... Ts> decltype(auto) draw(Ts &&...args) {
return m_draw(m_value, static_cast<decltype(args) &&>(args)...);
}
template <typename... Ts> decltype(auto) draw(Ts &&...args) const {
return m_draw(m_value, static_cast<decltype(args) &&>(args)...);
}
Finally, we need to initialize both m_value
and m_draw
object. It is easy, we calls the std::any
constructor and the create_caller
function.
template <typename U>
dyn(U x)
: m_value(std::move(x)),
m_draw{drawInformation<decltype(&dyn_type::draw)>::create_caller<U>()} {
}
C++ Type Erasure implementation: Entering the realm of single argument Macro
First, it is important to have a macro that concatenates two words:
#define DYN_CAT_IMPL(x, ...) x##__VA_ARGS__
#define DYN_CAT(...) DYN_CAT_IMPL(__VA_ARGS__)
Now that we have the basic macro, let’s see what are the needed steps.
- A macro defining the
MethodInformation
we saw before - A macro defining the function call
- A macro initializing the function pointers
- A macro calling the prior macros
First, let’s write the wrapper macro:
#define MAKE_DYN(type, function) \
template <> class dyn<type> { \
using dyn_type = type; \
std::any m_value; \
\
MAKE_METHOD_INFORMATION(function) \
public: \
MAKE_METHOD_CALL(function) \
\
template <typename U> \
dyn(U x) \
: m_value(std::move(x)), \
INITIALIZE_METHOD_INFORMATION(function) {} \
};
That one is easy and straightforward.
Next, let’s define the MAKE_METHOD_INFORMATION
macro.
#define MAKE_METHOD_INFORMATION(function) \
template <typename T> struct DYN_CAT(function, Information); \
template <typename ReturnType, typename... Args> \
struct DYN_CAT(function, \
Information)<ReturnType (dyn_type::*)(Args...) const> { \
using pointer_function = ReturnType (*)(const std::any &, Args...); \
template <typename U> static auto create_caller() { \
return +[](const std::any &x, Args... xs) { \
std::any_cast<U>(&x)->function(fwd(xs)...); \
}; \
} \
}; \
\
template <typename ReturnType, typename... Args> \
struct DYN_CAT(function, Information)<ReturnType (dyn_type::*)(Args...)> { \
using pointer_function = ReturnType (*)(std::any &, Args...); \
template <typename U> static auto create_caller() { \
return +[](std::any &x, Args... xs) { \
std::any_cast<U>(&x)->function(fwd(xs)...); \
}; \
} \
}; \
typename DYN_CAT( \
function, Information)<decltype(&dyn_type::function)>::pointer_function \
DYN_CAT(m_, function);
This one is a bit difficult. It is done in 4 steps:
- Declaring the
functionInformation
object - Declaring the
functionInformation
forconst
interfacepointer_function
taking aconst std::any&
andArgs...
- Declaring the
functionInformation
for mutable interfacepointer_function
taking astd::any&
andArgs...
- Declaring the
m_function
pointer to function.
Then, we define the MAKE_METHOD_CALL
macro.
#define MAKE_METHOD_CALL(function) \
template <typename... Ts> decltype(auto) function(Ts &&...args) { \
return DYN_CAT(m_, function)(m_value, fwd(args)...); \
} \
\
template <typename... Ts> decltype(auto) function(Ts &&...args) const { \
return DYN_CAT(m_, function)(m_value, fwd(args)...); \
}
This one is easy, we just forward the call to the pointer to function.
Finally, we initialize the pointers to function through INITIALIZE_METHOD_INFORMATION
.
#define INITIALIZE_METHOD_INFORMATION(function) \
DYN_CAT(m_, function) { \
DYN_CAT(function, \
Information)<decltype(&dyn_type::function)>::create_caller<U>() \
}
So, if we want to have the same structure for dyn<Drawable>
, we can write: MAKE_DYN(Drawable, draw)
.
Handling different actions
Our current implementation is cool and ready. Nevertheless, we have a flaw. Let’s take an example to illustrate it.
struct Openable {
void open(std::string);
void close();
};
MAKE_DYN(Openable, close, open)
Our current MAKE_DYN
macro is not able to take more than one action. We need to adjust it.
#define MAKE_DYN(type, ...) \
template <> class dyn<type> { \
using dyn_type = type; \
std::any m_value; \
\
DYN_MAP(MAKE_METHOD_INFORMATION, __VA_ARGS__) \
\
public: \
DYN_MAP(MAKE_METHOD_CALL, __VA_ARGS__) \
\
template <typename U> \
dyn(U x) \
: m_value(std::move(x)), \
DYN_MAP_COMA(INITIALIZE_METHOD_INFORMATION, __VA_ARGS__) {} \
};
Not much more difficult than before although we introduced two new macros: DYN_MAP
and DYN_MAP_COMA
.
DYN_MAP(macro, a, b, c)
returns =>macro(a); macro(b); macro(c)
DYN_MAP_COMA(macro, a, b, c)
returns =>macro(a), macro(b), macro(c)
They are easy to implement, we just need to use DYN_MAP_WITH_DELIMITER
.
#define DYN_SEMI_COLON() ;
#define DYN_COMA() ,
#define DYN_MAP(macro, ...) \
DYN_MAP_WITH_DELIMITER(macro, DYN_SEMI_COLON, __VA_ARGS__)
#define DYN_MAP_COMA(macro, ...) \
DYN_MAP_WITH_DELIMITER(macro, DYN_COMA, __VA_ARGS__)
The most assidue readers of this blog already know how to implement DYN_MAP_WITH_DELIMITER
with a C++11 compliant implementation.
Let’s do it in a more simple way with __VA_OPT__(x)
which expands to x if __VA_ARGS__
is not empty.
#define DYN_MAP_IMPL(macro, delimiter, x, ...) \
macro(x) __VA_OPT__( \
DYN_DEFER(DYN_MAP_I)(delimiter)(macro, delimiter, __VA_ARGS__))
#define DYN_MAP_I(delimiter) delimiter() DYN_MAP_IMPL
#define DYN_MAP_WITH_DELIMITER(macro, delimiter, ...) \
DYN_EVAL(DYN_MAP_IMPL(macro, delimiter, __VA_ARGS__))
We force multiple evaluation of the macro preprocessor with the help of DYN_EVAL
. The concrete implementation DYN_MAP_IMPL
:
- calls the macro with the first argument
- If the
__VA_ARGS__
is not empty, defer the callDYN_MAP_I
to the next preprocessor expansion.
The DYN_MAP_I
macro calls the delimiter and prepare the call to the DYN_MAP_IMPL
macro.
The implementation of DYN_DEFER
and DYN_EVAL
is identical as the one we saw few years ago:
#define DYN_EMPTY()
#define DYN_DEFER(...) __VA_ARGS__ DYN_EMPTY()
#define DYN_EXPAND(...) __VA_ARGS__
#define DYN_EVAL1(...) DYN_EXPAND(DYN_EXPAND(__VA_ARGS__))
#define DYN_EVAL2(...) DYN_EVAL1(DYN_EVAL1(__VA_ARGS__))
#define DYN_EVAL3(...) DYN_EVAL2(DYN_EVAL2(__VA_ARGS__))
#define DYN_EVAL4(...) DYN_EVAL3(DYN_EVAL3(__VA_ARGS__))
#define DYN_EVAL5(...) DYN_EVAL4(DYN_EVAL4(__VA_ARGS__))
#define DYN_EVAL6(...) DYN_EVAL5(DYN_EVAL5(__VA_ARGS__))
#define DYN_EVAL(...) DYN_EVAL6(DYN_EVAL6(__VA_ARGS__))
Conclusion
To conclude, we saw a macro based way to implement C++ type erasure. Thanks to macros, we can reduce by a lot the boiler plate. What we sum-up in this article will be the base of my Erased library.
You can play with this code here.
If this article pleases you, I can write an article to optimize a bit that implementation.
Thanks a lot for reading!
Leave a Reply