第18.6章 改用类型擦除
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 的代价
- 每次调用两次加载,和虚调用一样。不会更糟: 调用仍然是间接的,一般情形下仍然不能内联。
- 每个值一次分配,除非你加一个小缓冲区。这是诚实的代价,
而且和
unique_ptr版本付的是同一次分配 —— 区别在于它藏在一个类型里面,而这个类型以后可以加上小缓冲区优化, 而用户代码一行都不用改。这就是“分配细节藏在类自己里面”的实际含义。 - 表要你自己写。每个操作每个类型四行。
换来的是你控制 ABI、你能给表定版本、你能把它跨
dlopen边界传递。 - 没有到具体类型的隐式转换。这就是重点,它偶尔会疼:
如果你需要把类型拿回来,你要的不是类型擦除,
而是
::std::variant加::std::visit(第16.8章)。
7. 经验法则
- 每当你发现自己要写
vector<unique_ptr<Base>>,就该改用类型擦除。 - 把表放进
inline constexpr变量模板,用指定初始化器填, 让 lambda 无捕获,这样它们能转换成函数指针。 - 让值类型的拷贝、移动、交换、析构都是完整函数:拷贝克隆、
移动窃取并置空、析构是
noexcept、赋值是拷贝并交换。 - 在
make_shape里用malloc/free(或你自己的区域),不要用new:没有bad_alloc、 没有遗留异常,失败可以用throw throws报告 (第18.7章)。 - 只暴露操作。API 一旦长出
template<typename T> T* get_if(), 你就用更多代码、更少安全性重建了dynamic_cast。 - 记住
fast_io自己的print/scan机制正是这么工作的:每个类型一张操作表,编译期选定, 不用virtual、不用 RTTI 地分派 (第15.11章)。