Ch15.10: Type Erasure: shape, Without Inheritance
1. The Problem: A Container of Different Things
A ::fast_io::vector<rectangle> cannot hold a circle.
Templates are resolved at compile time and a vector's element type is
fixed at compile time, so “a collection of shapes of any kind” needs
something else.
There are exactly two answers in C++:
- Inheritance with
virtual(Chapter 18): a base classshapewith virtual functions, and avector<::std::unique_ptr<shape>>. It works, and it forces every concrete shape to inherit, which means its author must know about your base class and cannot make it astructwith two numbers in it. - Type erasure, this page: one concrete, non-template class
shapethat stores any type satisfying a concept, and dispatches through a table of function pointers. No inheritance, no base class, novirtualkeyword, no changes torectangleorcircle.
The second is how Adobe's value semantics libraries and Sean Parent's “inheritance is the base class of evil” talk do it, and it is what this page builds in full.
2. What shape Must Do
| Requirement | How |
|---|---|
hold a rectangle, a circle or a
square |
void * to a heap object of the concrete type |
call area(), name(), grow() on the
held object |
a struct of function pointers, one per operation |
| behave like a value: copy, move, destroy | two more function pointers: clone and destroy |
| be usable where the concrete type is known | a concept, shape_like, so the same algorithms work both ways |
That is the entire design. The function-pointer table is a vtable written by
hand; the difference from virtual is that nothing about the
concrete type appears in the type system — rectangle has no idea
shape exists.
3. The Concrete Types and the Concept
Nothing here is special. Three structs, each with the same three
noexcept members, each written as if shape did not exist:
struct rectangle
{
::std::float64_t w{}, h{};
[[nodiscard]] ::std::float64_t area() const noexcept { return w * h; }
[[nodiscard]] ::std::string_view name() const noexcept { return "rectangle"; }
void grow(::std::float64_t d) noexcept { w += d; h += d; }
};
struct square
{
::std::float64_t side{};
[[nodiscard]] ::std::float64_t area() const noexcept { return side * side; }
[[nodiscard]] ::std::string_view name() const noexcept { return "square"; }
void grow(::std::float64_t d) noexcept { side += d; }
};
struct circle
{
::std::float64_t r{};
[[nodiscard]] ::std::float64_t area() const noexcept { return ::std::numbers::pi * r * r; }
[[nodiscard]] ::std::string_view name() const noexcept { return "circle"; }
void grow(::std::float64_t d) noexcept { r += d; }
};
The concept states what shape needs, including the error contracts: all
three operations are noexcept, so the erased versions can be
noexcept too
(Ch15.3):
template <typename T>
concept shape_like = requires(T const c, T t, ::std::float64_t d) {
{ c.area() } noexcept -> ::std::same_as<::std::float64_t>;
{ c.name() } noexcept -> ::std::convertible_to<::std::string_view>;
{ t.grow(d) } noexcept -> ::std::same_as<void>;
};
The same concept lets the compile-time version of the algorithm exist next to the erased one, with no code shared and no virtual call:
template <shape_like S, ::std::size_t N>
[[nodiscard]] inline ::std::float64_t total_area(S const (&arr)[N]) noexcept
{
::std::float64_t sum{};
for (auto const &s : arr)
{
sum += s.area();
}
return sum;
}
4. The Hand-Written Vtable
Five function pointers. The first three are the operations; the last two are what
makes shape a value type rather than a borrowed reference:
class shape
{
struct vtable
{
::std::float64_t (*area)(void const *) noexcept;
::std::string_view (*name)(void const *) noexcept;
void (*grow)(void *, ::std::float64_t) noexcept;
void *(*clone)(void const *) throws;
void (*destroy)(void *) noexcept;
};
clone is throws because it allocates; the other four are
noexcept, which the concept guaranteed. The void * parameter
is the price of erasure: the table's functions cannot know the concrete type, so they
cast back to it, and that cast is safe because the table and the pointer are always
set together.
One table is generated per concrete type, by a static constexpr member
whose initialiser is a set of capture-less lambdas converted to function pointers (the
leading + forces the conversion):
template <typename T>
static constexpr vtable table_for{
+[](void const *p) noexcept -> ::std::float64_t { return static_cast<T const *>(p)->area(); },
+[](void const *p) noexcept -> ::std::string_view { return static_cast<T const *>(p)->name(); },
+[](void *p, ::std::float64_t d) noexcept { static_cast<T *>(p)->grow(d); },
+[](void const *p) throws -> void * { return allocate_and_copy<T>(*static_cast<T const *>(p)); },
+[](void *p) noexcept
{
static_cast<T *>(p)->~T();
allocator::deallocate_aligned_n(p, alignof(T), sizeof(T));
}};
This is the only template inside shape, and each instantiation is five
one-line thunks — the actual work stays in the concrete type, which is the
detemplatization rule of Ch15.9
applied to dispatch.
5. Storage: One Allocation per Shape
The erased object lives on the heap, allocated with fast_io's allocator
(::fast_io::native_global_allocator) rather than ::new, so
that allocation failure becomes a herbception with a defined effect and the alignment
is explicit:
using allocator = ::fast_io::native_global_allocator;
template <typename T>
[[nodiscard]] static void *allocate_and_copy(T const &v) throws
{
auto const res{allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
void *const p{res.ptr};
::new (p) T(v);
return p;
}
allocate_aligned_at_least(alignof(T), sizeof(T)) returns
{ptr, count} and may throw ::std::error;
deallocate_aligned_n(p, align, n) is the matching
noexcept release, which is why the destructor thunk can be
noexcept. Placement new constructs in the raw storage; no
other allocation happens.
A heap allocation per shape is the honest cost of this design. The alternative — a union big enough for the largest shape — saves the allocation and breaks the moment a shape is larger than the union or has an alignment you did not anticipate. Choose the heap until a profiler says otherwise.
6. The Rule of Five, Again
Chapter 14 said: a class that owns a resource must
answer five questions. shape owns a heap object, so it faces the same
five, with clone and destroy doing the work:
| Member | Contract | Implementation |
|---|---|---|
| constructor | throws |
allocate, construct, take the table |
| copy constructor | throws |
clone the held object |
| move constructor | noexcept |
steal both the pointer and the table, null the source |
| copy assignment | throws |
copy and swap (Ch14.8) |
| move assignment | noexcept |
swap (Ch14.6) |
| destructor | noexcept |
destroy the held object |
Two invariants hold the class together, and every member preserves them:
ptr_ and vt_ are either both null or both valid, and the
table is exactly the one that matches the object behind ptr_. That is why
swap swaps both and nothing else.
7. The Whole Program
#include <fast_io.h>
#include <stdfloat>
#include <numbers>
#include <string_view>
#include <utility>
#include <concepts>
struct rectangle
{
::std::float64_t w{}, h{};
[[nodiscard]] ::std::float64_t area() const noexcept { return w * h; }
[[nodiscard]] ::std::string_view name() const noexcept { return "rectangle"; }
void grow(::std::float64_t d) noexcept { w += d; h += d; }
};
struct square
{
::std::float64_t side{};
[[nodiscard]] ::std::float64_t area() const noexcept { return side * side; }
[[nodiscard]] ::std::string_view name() const noexcept { return "square"; }
void grow(::std::float64_t d) noexcept { side += d; }
};
struct circle
{
::std::float64_t r{};
[[nodiscard]] ::std::float64_t area() const noexcept { return ::std::numbers::pi * r * r; }
[[nodiscard]] ::std::string_view name() const noexcept { return "circle"; }
void grow(::std::float64_t d) noexcept { r += d; }
};
template <typename T>
concept shape_like = requires(T const c, T t, ::std::float64_t d) {
{ c.area() } noexcept -> ::std::same_as<::std::float64_t>;
{ c.name() } noexcept -> ::std::convertible_to<::std::string_view>;
{ t.grow(d) } noexcept -> ::std::same_as<void>;
};
template <shape_like S, ::std::size_t N>
[[nodiscard]] inline ::std::float64_t total_area(S const (&arr)[N]) noexcept
{
::std::float64_t sum{};
for (auto const &s : arr)
{
sum += s.area();
}
return sum;
}
class shape
{
struct vtable
{
::std::float64_t (*area)(void const *) noexcept;
::std::string_view (*name)(void const *) noexcept;
void (*grow)(void *, ::std::float64_t) noexcept;
void *(*clone)(void const *) throws;
void (*destroy)(void *) noexcept;
};
using allocator = ::fast_io::native_global_allocator;
template <typename T>
[[nodiscard]] static void *allocate_and_copy(T const &v) throws
{
auto const res{allocator::allocate_aligned_at_least(alignof(T), sizeof(T))};
void *const p{res.ptr};
::new (p) T(v);
return p;
}
template <typename T>
static constexpr vtable table_for{
+[](void const *p) noexcept -> ::std::float64_t { return static_cast<T const *>(p)->area(); },
+[](void const *p) noexcept -> ::std::string_view { return static_cast<T const *>(p)->name(); },
+[](void *p, ::std::float64_t d) noexcept { static_cast<T *>(p)->grow(d); },
+[](void const *p) throws -> void * { return allocate_and_copy<T>(*static_cast<T const *>(p)); },
+[](void *p) noexcept
{
static_cast<T *>(p)->~T();
allocator::deallocate_aligned_n(p, alignof(T), sizeof(T));
}};
void *ptr_{};
vtable const *vt_{};
public:
template <shape_like T>
explicit shape(T v) throws : ptr_{allocate_and_copy<T>(v)}, vt_{&table_for<T>}
{
}
shape(shape const &other) throws
: ptr_{other.ptr_ == nullptr ? nullptr : other.vt_->clone(other.ptr_)}, vt_{other.vt_}
{
}
shape(shape &&other) noexcept : ptr_{other.ptr_}, vt_{other.vt_}
{
other.ptr_ = nullptr;
other.vt_ = nullptr;
}
shape &operator=(shape const &other) throws
{
shape tmp{other};
swap(tmp);
return *this;
}
shape &operator=(shape &&other) noexcept
{
swap(other);
return *this;
}
~shape() noexcept
{
if (ptr_ != nullptr)
{
vt_->destroy(ptr_);
}
}
void swap(shape &other) noexcept
{
::std::swap(ptr_, other.ptr_);
::std::swap(vt_, other.vt_);
}
[[nodiscard]] ::std::float64_t area() const noexcept { return vt_->area(ptr_); }
[[nodiscard]] ::std::string_view name() const noexcept { return vt_->name(ptr_); }
void grow(::std::float64_t d) noexcept { vt_->grow(ptr_, d); }
};
int main()
{
try
{
shape a{rectangle{3.0, 4.0}};
shape b{circle{3.0}};
shape e{square{2.0}};
::fast_io::io::println(a.name(), " ", a.area());
::fast_io::io::println(b.name(), " ", b.area());
::fast_io::io::println(e.name(), " ", e.area());
a.grow(1.0);
::fast_io::io::println(a.name(), " ", a.area());
shape c{a};
c.grow(1.0);
::fast_io::io::println("copy: ", a.area(), " ", c.area());
rectangle const rects[2]{rectangle{1.0, 2.0}, rectangle{3.0, 4.0}};
::fast_io::io::println("total_area(rects)=", total_area(rects));
shape d{::std::move(c)};
::fast_io::io::println("moved: ", d.name(), " ", d.area());
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
Output:
rectangle 12
circle 28.274333882308138
square 4
rectangle 20
copy: 20 30
total_area(rects)=14
moved: rectangle 30
Read the transcript as a checklist: three concrete types in one class, a copy that is
independent (20 and 30), a move that keeps the value, and the
compile-time algorithm still usable on a plain array of
rectangle.
8. Erasure vs Inheritance: the Honest Comparison
Inheritance with virtual |
Type erasure (this page) | |
|---|---|---|
| Concrete types must know the base | yes — they inherit from it | no — any type meeting the concept |
| Adding an operation | edit the base class, recompile everything | edit the table and the class; concrete types untouched |
| Adding a concrete type | must be written as a derived class | write it anywhere; shape s{my_new_thing{}}; |
| Value semantics | no — needs pointers, cloning, careful ownership | yes — copies, moves, destructs; put it in a
::fast_io::vector |
| Intrusiveness | the base class owns the interface | a concept owns the interface; nothing is edited |
| Cost per call | one virtual dispatch | one indirect call through the table |
| Library compatibility | third-party types cannot be adapted without a wrapper | adapt by adding a table instantiation, or by a zero-cost wrapper |
The cost is the same in both: one indirect call, which the branch predictor usually handles well because the same table keeps being used. What changes is who has to cooperate. That is why value-semantic type erasure is the right default for values — shapes, colours, loggers, allocators — and inheritance is the right tool when the interface really is a hierarchy being extended by others.
9. When You Do Not Need Erasure at All
Before writing a table of function pointers, check that the type really has to be decided at run time. Three cheaper answers, in order of preference:
- A template. If one call site handles one concrete type, a template
is faster (inlined, no allocation) — that is
total_areaabove. - A variant. A closed set of types known at compile time can be a
::std::variantand a visitor; no allocation, no table, exhaustive checking by the compiler. - A concept plus a type switch at the boundary. Erase exactly once, where the decision is made, and keep everything below it templated.
Erase at the boundary, not in the middle. A program whose every layer is type-erased has all of the costs and none of the benefits — Ch11.17 makes the same argument for I/O layering.