Deriving Type Erasure

Ever wondered how `std::any` works? This article breaks down the classic type erasure technique, guiding you to build a minimal `std::any` from scratch using familiar tools like virtual functions and templates.
Deriving Type Erasure
Ever looked at std::any
and wondered what’s going on behind the scenes? Beneath the intimidating interface is a classic technique called type erasure: concrete types hidden behind a small, uniform wrapper.
Starting from familiar tools like virtual functions and templates, we’ll
build a minimal std::any
. By the end, you’ll have a clear understanding of how type erasure works under the hood.
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<std::unique_ptr<Shape>>& shapes) -> void {
for (const auto& shape : shapes) {
std::println("Area is {:.2f}", shape->area());
}
}
auto main() -> int {
auto shapes = std::vector<std::unique_ptr<Shape>>{};
shapes.emplace_back(std::make_unique<SquareWrapper>(Square{2}));
shapes.emplace_back(std::make_unique<CircleWrapper>(Circle{1}));
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 ~Co
Source: Hacker News

















