C++ type erasure implementation: a low boilerplate approach

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:

  1. You don’t need pointers anymore with polymorph objects.
  2. 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.

  1. std::any: for the construction and deletion of the object.
  2. pointer to member function: to construct the vtable
  3. A system that computes return type and the arguments.
  4. An interface’s name
  5. 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.

  1. A macro defining the MethodInformation we saw before
  2. A macro defining the function call
  3. A macro initializing the function pointers
  4. 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:

  1. Declaring the functionInformation object
  2. Declaring the functionInformation for const interface
    • pointer_function taking a const std::any& and Args...
  3. Declaring the functionInformation for mutable interface
    • pointer_function taking a std::any& and Args...
  4. 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 :

  1. calls the macro with the first argument
  2. If the __VA_ARGS__ is not empty, defer the call DYN_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!

Comments

Leave a Reply