Type erasing (TE is this article) is used to decouple the interface (behavior) and the type information in C++. In Python, it’s also called ”duck typing” with dynamic type binding:

def foo(duck):
    duck.walk()

Whatever the type of argument is, the only requirement is it contains a walk() function. It offers extreme flexibility for programmers, at the expense of runtime overhead for dynamic type binding. In static type programming language like C++, it is achieved with template and derivation.

Copyright: The following codes are referred from post1 in Zhihu. One can go to the compiler explorer website2 to compile it. Some contents are motivated from this blog3.

Base type to define the behavior

Suppose we want a universal type capable of calling a function without any parameter and returns nothing, i.e., void(). We define a pure base type to express the behavior:

struct task_base {
  virtual ~task_base() {}
  virtual void operator()() const = 0;
}

Note that any type derived from task_base is able to invoked by the operator() once override. However, this requires all used type should declared as a derived type of task_base, which is infeasible in most cases (e.g., for native builtin type int, or type from third party libraries).

Derived type to erase the type info

The ideal use case is we can bind any type (derived or not) to a “universal type”, which provides the interface to execute the behavior:

void foo() {
  std::cout << "type erasure 1";
}
my_task t1{&foo};
t1()

To achieve this, we should introduce a middle type to “hide” the the type passed in:

template <typename F>
struct task_model : public task_base {
  F functor_;
  template <typename U>
  task_model(U&& f) : functor_(std::forward<U>(f)) {}
  void operator()() const override {
    functor_();
  }
};

The constructor itself is a templated function receiving the actual type as universal reference, and the class template type F is inferred from the universal type U. Meanwhile, it’s also a type derived from the task_base class to express the expected behavior (via the override operator() function).

Putting together

We cannot directly use task_model as it’s still the derived type. Hence another proxy is needed to completely erase the type:

class my_task {
  std::unique_ptr<task_base> ptr_;
 public:
  template <typename F>
  my_task(F&& f) {
    using model_type = task_model<F>;
    ptr_ = std::make_unique<model_type>(std::forward<F>(f));
  }
  void operator()() const {
    ptr_->operator()();
  }
};

Now users are able to define a my_task instance by any “callable” type ( void (), i.e., the type is able to be invoked with zero parameter, and returns void). my_task itself is not a derived type, nor contains any virtual function.

If the type passed in is a builtin type or from third party library, we can define a special version of task_model with partial specialization:

template <>
struct task_model<int> : public task_base {
  void operator()() {
  }
};

Summary

The user-defined type is not necessary to derive from any class. The pure base class defines the required interfaces, from whom the templated middle class derives from.

The advantages of type erasure are:

  • No derivation constraints, user don’t need to define their custom type as the derived class.
  • No runtime binding overhead, the resolution and binding behavior happens at compile time via the template instantiation.

Footnotes

  1. https://zhuanlan.zhihu.com/p/351291649 Chinese

  2. https://godbolt.org/z/M9rx8n6av

  3. https://fuzhe1989.github.io/2017/10/29/cpp-type-erasure/