第18.6章 改用类型擦除

English

1. 同一件事,用值来做

在第18.1章我们写的是 ::fast_io::vector<::std::unique_ptr<shape>>: 一个装着“指向抽象基类的拥有型指针”的容器,每个元素一次堆分配,每次访问一次虚调用。 现在我们写 ::fast_io::vector<shape_holder>: 一个装值的容器,而 shape_holder 是一个非模板类型, 能装下任何形状。

这就是类型擦除,也是 Portable-Cpp-Guideline 推荐的、用来替代多态中 virtual 的技术。 一句话说清:把类型信息放进一张在编译期生成的函数表, 只把一个 void* 和一个指向那张表的指针放进值类型。

让它在现代 C++ 里成立的有两件事:

2. 完整程序


#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 就是全部测试:第一个循环累加 4 + 3 + 6 = 13, 拷贝循环从一个容器的深拷贝里减去同样的 13,结果是零。 拷贝能工作,是因为 shape_holder 有一个普通的拷贝构造函数; main 里没有任何代码知道涉及了三种不同的类型。

3. 读那四个活动部件

表

shape_vtable 就是一个装着四个函数指针的普通 struct。 它就是整个接口。没有基类、没有继承、没有 virtual、没有 RTTI —— 只有数据。加一个操作就是加一个字段,每个类型加一行。

表的实例

shape_vtable_for<T> 是一个 inline constexpr 变量模板,所以每个被实例化的类型在程序里恰好一张表, 在只读数据里,地址固定 —— 和 vtable 有同样的性质, 但用的是你能读懂的机制。指定初始化器让字段与名字对应, 所以以后加字段时,每个没有设置它的表实例都会编译错误。

分派器

shape_area 和 shape_name 是一个接口同时服务两个世界的方式: 如果类型有成员 area() 就用它, 否则用参数依赖查找找到的自由函数 area(x)。 这是 virtual 永远不可能有的性质: virtual 要求类型为你的接口而写 (继承、覆盖、接受 vptr),而一个表项可以调用类型已经提供的一切, 包括一个自由函数,或者一个你改不了的头文件里的 C API。

值

shape_holder 是两个指针:对象和表。16 字节, 原则上可以平凡重定位,带五个普通的特殊成员函数 (第16.4章): 拷贝克隆、移动窃取并把源置空、析构调用表里的 destroy、 赋值是拷贝并交换。你的代码里没有 new —— 分配发生在 make_shape 里,那是这个类自己的事。

4. 编译器不再生成什么

同一个程序,两种写法,比较符号表:


$ 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>

virtual 版本带着:三个 typeinfo 对象、 三个 typeinfo name 字符串、两个 vtable, 以及从 C++ 运行时拉进来的两个 __cxxabiv1 class-type-info vtable。 类型擦除版本带着三个 shape_vtable_for 对象, 它们不过是装着函数指针的 constexpr 结构体。

擦除版二进制里同样没有的:__cxa_pure_virtual 以及它拖来的一切(第18.4章): 没有 std::terminate 处理器、没有 demangler、 没有 fwrite/fputs。 你可以不加任何修改地把它用 -fno-rtti 和 -fno-exceptions 链接,这就是它能用在 freestanding 代码里的原因 (第20章 freestanding)。

5. 消失掉的那四类 bug

bug 用 unique_ptr<base> + virtual 用类型擦除的值
释放后使用 release()、第二个裸指针、或者活过拥有者死亡时刻的引用; 这时 vptr 就是分配器写进去的东西 没有裸指针可以留。表示是私有的,只能通过值到达, 而值的析构函数是唯一能释放它的代码
重复释放 一次错误的 std::move 之后有两个拥有者、 或者基类没有虚析构、或者 delete 一个已经被拥有的指针 只有一处 destroy 调用点,在一个析构函数里; 移动把源置空,所以被移动后的值什么都不释放
类型混淆 把 static_cast 转到错误的派生类型、 基类指针被复用给另一个对象、 或者 dynamic_cast 返回空而空被直接用了 具体类型从外部根本无法命名。没有转换可写、没有类型可混淆: 你拿到的是接口,也就是四个函数
vptr 注入 越过成员末尾的一次写入落进对象头部、覆盖 vptr, 下一次虚调用就跳进数据里 对象没有 vptr,也没有可写的分派指针。表指针是一个私有 const 成员,指向 .rodata 里的 constexpr 对象;载荷里的堆越界够不到它, 就算够到了,也没有任何东西能让它指向可执行代码

最后一行是人们最低估的。一个多态对象就是一个可写的、指向代码的指针, 存在于每个实例、位于已知偏移,在一个没有边界检查的语言里。 类型擦除把它换成一个 const 指针,指向一张活在只读内存里、 由类型系统在编译期选定的表。

6. 相对 virtual 的代价

7. 经验法则