第15.10章 类型擦除:不用继承的 shape

English

1. 问题:装着不同东西的容器

::fast_io::vector<rectangle> 装不下 circle。 模板在编译期解析,vector 的元素类型也在编译期固定,所以“任意形状的集合” 需要别的办法。

在 C++ 里恰好只有两个答案:

第二种就是 Adobe 那套值语义库以及 Sean Parent“继承是万恶之基”演讲的做法, 本页把它完整造出来。

2. shape 必须做到什么

要求 做法
装下 rectangle、circle、square 一个指向具体类型堆对象的 void *
对被装的对象调用 area()、name()、grow() 一个函数指针 struct,每个操作一个指针
像值一样工作:拷贝、移动、析构 再加两个函数指针:clone 与 destroy
在具体类型已知处也能用 一个概念 shape_like,让同一套算法两条路都走得通

设计就这么点。那张函数指针表就是手写的 vtable;与 virtual 的差别在于,具体类型的任何信息都没有进入类型系统 —— rectangle 根本不知道 shape 存在。

3. 具体类型与概念

这里没有任何特殊之处。三个 struct,每个都有同样三个 noexcept 成员,每个都写得好像 shape 不存在:


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; }
};

概念写明了 shape 需要什么,连错误契约一起:三个操作都是 noexcept,所以擦除后的版本也可以 noexcept (第15.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>;
};

同一个概念让算法的编译期版本能与擦除版本并存,既不共享代码也没有虚调用:


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. 手写的 vtable

五个函数指针。前三个是操作,后两个是让 shape 成为值类型、而不是借用引用的东西:


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 是 throws,因为它要分配;另外四个是 noexcept,这是概念已经保证的。void * 形参是擦除的代价: 表里的函数不可能知道具体类型,所以它们要转回去;这个转换是安全的,因为表与指针总是被 一起设置。

每个具体类型生成一张表,靠一个 static constexpr 成员完成,它的初值是一组 无捕获 lambda 转成的函数指针(前面的 + 强制发生这次转换):


	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));
		}};

这是 shape 里唯一的模板,每个实例化只是五个一行的转发薄片 —— 真正干活的东西留在具体类型里,这就是把 第15.9章的去模板化规则用在了分派上。

5. 存储:每个形状一次分配

被擦除的对象住在堆上,用 fast_io 的分配器 (::fast_io::native_global_allocator)而不是 ::new 分配, 这样分配失败会变成行为明确的赫布异常,对齐也是显式的:


	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)) 返回 {ptr, count},并且可能抛 ::std::error; deallocate_aligned_n(p, align, n) 是配套的 noexcept 释放, 这就是析构薄片可以 noexcept 的原因。定位 new 在原始存储上构造, 没有别的分配发生。

每个形状一次堆分配是这套设计诚实的代价。替代方案 —— 一个足够大的 union —— 省掉分配, 但一旦某个形状比 union 大、或对齐是你没预料到的值,它就崩了。在性能剖析说别的之前, 选堆。

6. 再一次五法则

第14章说过:拥有资源的类必须回答五个问题。 shape 拥有一个堆对象,所以它面对同样五个,由 clone 与 destroy 干活:

成员 契约 实现
构造函数 throws 分配、构造、记下表
拷贝构造 throws 用 clone 复制被持有的对象
移动构造 noexcept 接管指针与表,把源置空
拷贝赋值 throws 拷贝并交换(第14.8章)
移动赋值 noexcept 交换(第14.6章)
析构函数 noexcept destroy 被持有的对象

两个不变式撑起这个类,并且每个成员都保持它们:ptr_ 与 vt_ 要么都为空、要么都有效;表与 ptr_ 后面的对象恰好匹配。 这就是 swap 只交换这两个成员的原因。

7. 完整程序


#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);
	}
}

输出:


rectangle 12
circle 28.274333882308138
square 4
rectangle 20
copy: 20 30
total_area(rects)=14
moved: rectangle 30

把这段运行记录当清单读:一个类里装三种具体类型;拷贝是独立的(20 与 30);移动保住了值;编译期算法仍然能用在普通 rectangle 数组上。

8. 擦除与继承:诚实的对比

带 virtual 的继承 类型擦除(本页)
具体类型必须知道基类 是 —— 它们继承自它 不 —— 任何满足概念的类型都行
增加一个操作 改基类,全部重编译 改表与类;具体类型不动
增加一个具体类型 必须写成派生类 在任何地方写都行:shape s{my_new_thing{}};
值语义 没有 —— 得靠指针、克隆、小心翼翼的归属管理 有 —— 可拷贝、可移动、可析构;能放进 ::fast_io::vector
侵入性 基类拥有接口 概念拥有接口;不改任何东西
每次调用代价 一次虚分派 一次经表的间接调用
与库兼容 第三方类型不写包装就没法适配 加一个表实例化即可适配

两种方案的代价相同:一次间接调用,而分支预测器通常处理得很好,因为一直用的是同一张表。 变的是“谁必须配合”。这就是为什么值语义类型擦除是值的默认选择 —— 形状、颜色、日志器、分配器 —— 而当接口真的是一套被他人扩展的层次结构时,继承才是对的工具。

9. 有时你根本不需要擦除

在写函数指针表之前,先确认类型真的必须在运行期决定。三个更便宜的答案,按优先级排列:

在边界处擦除,不要在中间擦除。如果程序每一层都被类型擦除,它会付掉全部代价而拿不到任何好处 —— 第11.17章对 I/O 分层讲过同样的道理。