Deriving Type Erasure
Ever looked at std::any and wondered what’s actually going on behind
the scenes? Beneath the intimidating interface is a clean case of type
erasure: concrete types hidden behind a small, uniform wrapper.
Starting from familiar tools—virtual functions and templates—we’ll
build a minimal std::any. Along the way, type erasure shifts from
buzzword to practical technique you can recognize and reuse in your own
designs.
Polymorphism with interfaces §
The typical way to achieve polymorphism is to define an interface consisting of pure-virtual methods you want to be able to call. Then, for each implementation that you want to use polymorphically, you create a subclass that inherits from the base class and implement those methods.
As an example, let’s implement shape classes that have an area()
method. We start with an interface1 1
Remember that interfaces that are intended to be used through a
Base& or Base* must have a virtual destructor, to ensure derived
classes are properly destructed (C.127).
class
class Shape {
public:
virtual ~Shape() = default;
virtual auto area() const noexcept -> double = 0;
};
And add a couple of concrete implementations for Square and Circle
class Square : public Shape {
int side_;
public:
explicit Square(int side) noexcept : side_{side} {}
auto area() const noexcept -> double override { return side_ * side_; }
};
class Circle : public Shape {
int radius_;
public:
explicit Circle(int radius) noexcept : radius_{radius} {}
auto area() const noexcept -> double override {
return std::numbers::pi * radius_ * radius_;
}
};
Now, we can use these implementations generically, by coding against the interface
auto printArea(const Shape& shape) -> void {
std::println("Area is {:.2f}", shape.area());
}
Simple enough, right?
Polymorphism with templates §
Inheritance is a good solution to problems that require polymorphism,
but sometimes the concrete types you want to handle polymorphically
cannot share a common base class.2 2
In some cases, you may not have control of the concrete types
(e.g. think STL types like std::string), or it may not even be
possible for the concrete type to inherit (e.g. builtins like int).
In that case, if the types
provide the same interface, you can use a template to get polymorphism
instead
auto printArea(const auto& shape) -> void {
std::println("Area is {:.2f}", shape.area());
}
You can use this method with Square, Circle, or any type that
provides a zero-argument area() returning double. Templates work
because the compiler generates a version of the function for each
concrete type you use, and the call is valid as long as that generated
code would compile3 3
If you tried to pass in a type that doesn’t conform to the
‘interface’ (say, std::string), the compiler would hit an error when
you try to compile the method call, complaining that std::string
doesn’t have an area method.
for the given type.
Unfortunately, template-based polymorphism has two main downsides.
First, templates do not give you one shared runtime base type like
Shape. Each instantiation is a distinct type, so there is no common
type for a homogeneous container; you cannot store a mix of Square and
Circle in one array and handle them uniformly the way you can with a
pointer to base technique
auto shapes = std::vector< ??? >{&square, &circle};
The second drawback4 4
Since you’re employing polymorphism in the first place, most
callers will likely fall into the second group, and will need to be
templates themselves too so they can pass the type through. That can
quickly spread templates across the codebase, making it harder to read
and structure, increasing compile times, and producing larger binaries
with slower startup.
is a little more subtle. Anybody who uses
the template-based area(const auto&) method must either explicitly
specify the concrete type, or be a template itself, to pass along the
template type of area().
Deriving std::any §
Imagine Square and Circle are fixed types with no shared base class,
and you cannot change them to inherit from one. But you still want to
handle them through a single common interface.
One way to do that is to introduce wrappers. Define your own Shape
interface, then create wrapper classes that inherit from Shape and
contain a Square or Circle; each wrapper implements the virtual
methods by simply forwarding calls to the wrapped object
class SquareWrapper : public Shape {
Square square_;
public:
explicit SquareWrapper(Square square) noexcept : square_{std::move(square)} {}
auto area() const noexcept -> double override { return square_.area(); }
};
class CircleWrapper : public Shape {
Circle circle_;
public:
explicit CircleWrapper(Circle circle) noexcept : circle_{std::move(circle)} {}
auto area() const noexcept -> double override { return circle_.area(); }
};
Now we can work directly with instances of Shape
auto printAreas(const std::vector<Shape*>& shapes) -> void {
for (auto* shape : shapes) {
std::println("Area is {:.2f}", shape->area());
}
}
auto main() -> int {
auto square = SquareWrapper{Square{2}};
auto circle = CircleWrapper{Circle{1}};
auto shapes = std::vector<Shape*>{&square, &circle};
printAreas(shapes);
}
This approach works, but it has an obvious downside: you need a separate
wrapper type (like CircleWrapper) for every concrete type you want to
adapt (like Circle), which quickly turns into a pile of
boilerplate. Luckily, templates can offload much of that work to the
compiler by generating the needed code for each type automatically
template <typename T>
class ShapeWrapper : public Shape {
T shape_;
public:
explicit ShapeWrapper(T shape) noexcept : shape_{std::move(shape)} {}
auto area() const noexcept -> double override { return shape_.area(); }
};
What we built above is the basis of the “type erasure” idiom. All
that’s left is to hide all this machinery behind another class, so that
callers don’t have to deal with our custom interfaces and
templates5 5
This implementation always heap-allocates. Production
std::any implementations often use small buffer optimization (SBO)
techniques to store small objects inline and avoid allocation.
class AnyShape {
class Shape { // The interface
public:
virtual ~Shape() = default;
virtual auto area() const noexcept -> double = 0;
};
template <typename T>
class ShapeWrapper : public Shape { // The wrappers
T shape_;
public:
explicit ShapeWrapper(T shape) noexcept : shape_{std::move(shape)} {}
auto area() const noexcept -> double override { return shape_.area(); }
};
std::unique_ptr<Shape> shape_;
public:
template <typename T>
explicit AnyShape(T&& shape)
: shape_{std::make_unique<ShapeWrapper<T>>(std::forward<T>(shape))} {}
auto area() const noexcept -> double { return shape_->area(); }
};
It works the same as before, but the wrapper logic is hidden from the consoomer
auto printAreas(const std::vector<AnyShape>& shapes) -> void {
for (const auto& shape : shapes) {
std::println("Area is {:.2f}", shape.area());
}
}
auto main() -> int {
auto shapes = std::vector<AnyShape>{};
shapes.emplace_back(Square{2});
shapes.emplace_back(Circle{1});
printAreas(shapes);
}
Generic std::any §
Both Shape and ShapeWrapper have accepted standard names: the former
is the type-erasure concept6 6
The type erasure concept is an OO-style interface (a vtable).
It’s unrelated to C++20 concept (compile-time predicates).
(the interface we program against),
and the latter is the model (a templated wrapper that implements the
interface and forwards to a concrete type).
Let’s rewrite our original type erasure example to use the standard parlance. Nothing needs to be changed except a few type names
#include <memory>
class Any {
class Concept {
public:
virtual ~Concept() = default;
virtual auto f() const noexcept -> double = 0;
};
template <typename T>
class Model : public Concept {
T obj_;
public:
explicit Model(T obj) noexcept : obj_{std::move(obj)} {}
auto f() const noexcept -> double override { return obj_.f(); }
};
std::unique_ptr<Concept> obj_;
public:
template <typename T>
explicit Any(T&& obj) : obj_{std::make_unique<Model<T>>(std::forward<T>(obj))} {}
auto f() const noexcept -> double { return obj_->f(); }
};
That’s it! The class Any is a simplified version of std::any, which
is even used in the STL itself (namely, in std::function). But that’s
for another post.