Ch18.6: Type Erasure Instead
1. The Same Job, Done With Values
In Ch18.1 we wrote
::fast_io::vector<::std::unique_ptr<shape>>: a container of
owning pointers to an abstract base, with one heap allocation per element and a virtual
call per access. Now we write ::fast_io::vector<shape_holder>: a
container of values, where shape_holder is one non-template type
that can hold any shape.
This is type erasure, and it is the technique the
Portable-Cpp-Guideline recommends instead of
virtual for polymorphism. The idea in one sentence: keep the type
information in a table of functions generated at compile time, and put only a
void* and a pointer to that table in the value type.
Two things make it work in modern C++:
- Function pointers are ordinary values. You can put them in a
constexprvariable template, one instance per type, with designated initializers (Ch2.21), and the compiler emits one table per instantiated type — exactly like a vtable, except that it is your type, your layout, and your rules. if constexprplus arequiresexpression lets one table serve types that were designed as classes and types that were never designed for anything (Ch15.8).
2. The Whole Program
#include <fast_io.h>
#include <fast_io_dsal/vector.h>
#include <string_view>
#include <type_traits>
#include <utility>
#include <cstdlib>
#include <system_error>
#include <new>
// three unrelated types: two OOP, one plain C-style struct
struct square
{
double side{};
double area() const noexcept { return side * side; }
::std::string_view name() const noexcept { return "square"; }
};
struct circle
{
double r{};
double area() const noexcept { return 3.0 * r * r; }
::std::string_view name() const noexcept { return "circle"; }
};
struct triangle
{
double b{};
double h{};
};
// the plain struct gets free functions instead of members
double area(triangle const &t) noexcept { return t.b * t.h * 0.5; }
::std::string_view name(triangle const &) noexcept { return "triangle"; }
// one dispatcher: member if it exists, free function otherwise
template <typename T>
constexpr double shape_area(T const &x) noexcept
{
if constexpr (requires { x.area(); })
{
return x.area();
}
else
{
return area(x);
}
}
template <typename T>
constexpr ::std::string_view shape_name(T const &x) noexcept
{
if constexpr (requires { x.name(); })
{
return x.name();
}
else
{
return name(x);
}
}
template <typename T, typename... Args>
T *make_shape(Args &&...args) throws
{
void *mem{::std::malloc(sizeof(T))};
if (mem == nullptr)
{
throw throws ::std::errc::not_enough_memory;
}
return ::new (mem) T(::std::forward<Args>(args)...);
}
template <typename T>
void destroy_shape(void *p) noexcept
{
static_cast<T *>(p)->~T();
::std::free(p);
}
struct shape_vtable
{
double (*area)(void const *) noexcept;
::std::string_view (*name)(void const *) noexcept;
void (*destroy)(void *) noexcept;
void *(*clone)(void const *) throws;
};
template <typename T>
inline constexpr shape_vtable shape_vtable_for{
.area = [](void const *p) noexcept { return shape_area(*static_cast<T const *>(p)); },
.name = [](void const *p) noexcept { return shape_name(*static_cast<T const *>(p)); },
.destroy = [](void *p) noexcept { destroy_shape<T>(p); },
.clone = [](void const *p) throws -> void * { return make_shape<T>(*static_cast<T const *>(p)); },
};
class shape_holder
{
void *obj_{};
shape_vtable const *vt_{};
public:
shape_holder() noexcept = default;
template <typename T>
requires(!::std::same_as<::std::remove_cvref_t<T>, shape_holder>)
shape_holder(T value) throws : obj_{make_shape<T>(::std::move(value))}, vt_{&shape_vtable_for<T>} {}
shape_holder(shape_holder const &other) throws
: obj_{other.vt_ != nullptr ? other.vt_->clone(other.obj_) : nullptr}, vt_{other.vt_}
{
}
shape_holder(shape_holder &&other) noexcept : obj_{other.obj_}, vt_{other.vt_}
{
other.obj_ = nullptr;
other.vt_ = nullptr;
}
shape_holder &operator=(shape_holder const &other) throws
{
shape_holder tmp{other};
swap(tmp);
return *this;
}
shape_holder &operator=(shape_holder &&other) noexcept
{
shape_holder tmp{::std::move(other)};
swap(tmp);
return *this;
}
~shape_holder() noexcept
{
if (vt_ != nullptr)
{
vt_->destroy(obj_);
}
}
void swap(shape_holder &other) noexcept
{
::std::swap(obj_, other.obj_);
::std::swap(vt_, other.vt_);
}
[[nodiscard]] double area() const noexcept { return vt_->area(obj_); }
[[nodiscard]] ::std::string_view name() const noexcept { return vt_->name(obj_); }
[[nodiscard]] explicit operator bool() const noexcept { return vt_ != nullptr; }
};
int main()
{
try
{
::fast_io::vector<shape_holder> shapes;
shapes.push_back(shape_holder{square{2.0}});
shapes.push_back(shape_holder{circle{1.0}});
shapes.push_back(shape_holder{triangle{3.0, 4.0}});
double total{};
for (auto const &s : shapes)
{
total += s.area();
}
::fast_io::vector<shape_holder> copied = shapes;
for (auto const &s : copied)
{
total -= s.area();
}
::fast_io::io::println("total=", total, " sizeof(shape_holder)=", sizeof(shape_holder),
" first=", shapes[0].name(), " last=", shapes[2].name());
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
total=0 sizeof(shape_holder)=16 first=square last=triangle
total=0 is the whole test: the first loop summed 4 + 3 + 6 = 13, the copy
loop subtracted the same 13 from a deep copy of the container, and the result is
zero. The copy worked because shape_holder has an ordinary copy constructor;
nothing in main knows that three different types were involved.
3. Reading the Four Moving Parts
The table
shape_vtable is a plain struct of four function pointers. It is
the entire interface. There is no base class, no inheritance, no virtual, no
RTTI — just data. Adding an operation means adding a field and one line per type.
The table instance
shape_vtable_for<T> is an inline constexpr variable
template, so there is exactly one table per instantiated type in the
program, in read-only data, with a fixed address — the same property a
vtable has, obtained by a mechanism you can read. Designated initializers keep the fields
matched to their names, so adding a field later is a compile error in every table
instance that did not set it.
The dispatcher
shape_area and shape_name are how one interface serves both
worlds: if the type has a member area() it is used, otherwise the free
function area(x) found by argument-dependent lookup is used. This is the
property virtual can never have: virtual requires the
type to be written for your interface (inherit, override, accept the vptr), while
a table entry can call anything the type already offers, including a free function or a
C API from a header you cannot modify.
The value
shape_holder is two pointers: the object and the table. Sixteen bytes,
trivially relocatable in principle, with five ordinary special members
(Ch16.4): copy clones, move steals and
nulls, destroy calls the table's destroy, assignment is copy-and-swap. There
is no new in your code — allocation happens in
make_shape, which is the class's own business.
4. What the Compiler Stops Emitting
The same program, both ways, with the symbol tables compared:
$ nm -C legacy_uptr | grep -E 'typeinfo|vtable'
typeinfo for shape typeinfo name for shape vtable for circle
typeinfo for circle typeinfo name for circle vtable for square
typeinfo for square typeinfo name for square vtable for __cxxabiv1::__class_type_info
vtable for __cxxabiv1::__si_class_type_info
$ nm -C erased | grep -E 'typeinfo|vtable'
shape_vtable_for<circle> shape_vtable_for<square> shape_vtable_for<triangle>
The virtual version carries: three typeinfo objects, three
typeinfo name strings, two vtables, and the two
__cxxabiv1 class-type-info vtables pulled in from the C++ runtime. The
type-erased version carries three shape_vtable_for objects, which are nothing
but constexpr structs of function pointers.
Also absent from the erased binary: __cxa_pure_virtual and everything it
drags in (Ch18.4): no
std::terminate handler, no demangler, no fwrite/fputs.
You can link it with -fno-rtti and with -fno-exceptions without
a single change, which is what makes this usable in freestanding code
(Ch20: Freestanding).
5. The Four Bug Classes That Disappear
| Bug | With unique_ptr<base> + virtual |
With a type-erased value |
|---|---|---|
| use after free | release(), a second raw pointer, or a reference kept past the
owner's death; the vptr is then whatever the allocator wrote |
there is no raw pointer to keep. The representation is private, reached only through the value, and the value's destructor is the only code that can free it |
| double free | two owners after a bad std::move, or a base without a virtual
destructor, or a delete of a pointer that was already owned |
one destroy call site, in one destructor; move nulls the source, so
the moved-from value frees nothing |
| type confusion | a static_cast to the wrong derived type, a base pointer reused for a
different object, or dynamic_cast returning null and the null being
used |
the concrete type cannot be named from outside at all. There is no cast to write and no type to confuse: you get the interface, which is four functions |
| vptr injection | a write past the end of a member lands in the object header, overwrites the vptr, and the next virtual call jumps into data | the object has no vptr and no writable dispatch pointer. The table pointer is a
private const member pointing at a constexpr object in
.rodata; a heap overrun in the payload cannot reach it, and even if it
could, there is nothing to point it at that would give code execution |
That last row is the one people underestimate. A polymorphic object is a
writable pointer to code, in every instance, at a known offset, in a language
with no bounds checking. Type erasure replaces it with a const pointer to a
table that lives in read-only memory and is chosen by the type system at compile time.
6. What It Costs Compared to virtual
- Two loads per call, like a virtual call. No worse: the call is still indirect, still not inlinable in the general case.
- One allocation per value, unless you add a small buffer. This is the
honest cost, and it is the same allocation the
unique_ptrversion pays — except that it is hidden inside a type that can later be given a small-buffer optimisation without changing one line of user code. That is what "the allocation details are hidden in the class itself" means in practice. - You write the table. Four lines per operation per type. In exchange
you control the ABI, you can version the table, and you can ship it across a
dlopenboundary. - No implicit conversion to the concrete type. That is the point, and
it occasionally hurts: if you need to get the type back, you did not want type erasure,
you wanted a variant with
::std::visit(Ch16.8).
7. Rules of Thumb
- Reach for type erasure whenever you catch yourself writing
vector<unique_ptr<Base>>. - Put the table in an
inline constexprvariable template, fill it with designated initializers, and let the lambdas be captureless so they convert to function pointers. - Make the value type's copy, move, swap and destructor total functions: copy clones,
move steals and nulls, destroy is
noexcept, assignment is copy-and-swap. - Use
malloc/free(or your own arena) insidemake_shape, notnew: nobad_alloc, no legacy exception, and a failure you can report withthrow throws(Ch18.7). - Expose only operations. The moment the API grows a
template<typename T> T* get_if(), you have rebuiltdynamic_castwith more code and less safety. - Remember that this is exactly how
fast_io's ownprint/scanmachinery works: one table of operations per type, chosen at compile time, dispatched withoutvirtualand without RTTI (Ch15.11).