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++:

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:

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.