C++ type erasure without macros

,

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.

  1. Develop a MethodTrait which
    • retrieve the signature thanks to invoker function
    • compute the pointer to function
    • Create a templated invoker
  2. Store the pointer to functions
  3. 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

  1. Inheritance of all the actions objects
  2. Two different storage:
    • storage of the concrete type
    • storage of the function pointers.
  3. Construct the object
  4. 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:

  1. 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.
  2. Write an article about small object optimization.
  3. Write an article to get rid of std::any by using what we saw on small object optimization.

Thanks!

Comments

Leave a Reply