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

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

7. Rules of Thumb