第18.2章 virtual 究竟是什么
1. 三件事,按顺序
virtual 函数不是魔法。它是三个机械事实:
- 多态类的每个对象都带一个隐藏指针(vptr),
指向它所在类的函数指针表。所以这种对象不可能比一个指针更小,
而且至少按
alignof(void*)对齐。 - 每个多态类在只读数据里有一张表(vtable), 每个虚函数一个槽位,按声明顺序排列。它由编译器生成;你从来不用写,也改不了。
- 虚调用是:读 vptr、读槽位、跳转。两次相关加载而不是一次直接跳转, 而且目标地址在运行时才知道,所以不可能通过它内联。
机制就这些。本章其余内容 —— 膨胀、RTTI、__cxa_pure_virtual 符号、ABI ——
全都是由它推出来的。
2. 它的字节代价
struct drawable
{
virtual ::std::string_view kind() const noexcept = 0;
virtual ~drawable() noexcept = default;
};
struct point : drawable
{
double x{}, y{};
point(double px, double py) noexcept : x{px}, y{py} {}
::std::string_view kind() const noexcept override { return "point"; }
};
struct square : drawable
{
double side{};
explicit square(double s) noexcept : side{s} {}
::std::string_view kind() const noexcept override { return "square"; }
};
sizeof(drawable)=8 sizeof(point)=24 sizeof(square)=16 through base=point through object=square
算一下:point 是两个 double(16 字节)加 vptr(8)= 24。
square 是一个 double(8)加 vptr(8)= 16。
一个只有一个 double 的形状,因为一个函数是虚的,体积翻了一倍;
而一旦把它放到 ::std::unique_ptr 后面,
它又变成一个指针加分配器头部,再翻一次
(第18.1章)。
和本章后面的类型擦除版本对比:一个指针加一个表指针,16 字节, 能装下任何形状,载荷里没有 vptr,头部也不用分配。
3. 为什么 virtual 只有配合分配才“划算”
有一种不用堆的 virtual 用法:把派生对象按值放进容器,通过引用调用。
point p{1.0, 2.0};
square q{3.0};
drawable &d = p; // dispatch through a reference
::fast_io::io::println(d.kind()); // "point"
这是正确且不分配的,但注意它没有给你什么:类型集合在编译期就固定了,
每个变量只有一个静态类型,你没法把一个 point 和一个
square 放进同一个 ::fast_io::vector。
你一旦想要“任何可绘制对象的容器”,virtual 能给你的唯一东西就是基类指针,
而活得比作用域长的基类指针需要一个拥有者,C++ 的拥有者是
::std::unique_ptr,而 ::std::unique_ptr 需要
new。
这就是那条链。virtual 本身并不是最贵的部分;
virtual 加上异构才是,而异构恰恰是人们选 OOP 时想要的东西。
在链条上任何一环断开它 —— 用值代替指针、用类型擦除代替继承 —— 整套代价就消失了。
4. 诚实的代价模型
| 你要付什么 | 什么时候 | 本章里的实测 |
|---|---|---|
| 每个对象 8 字节 | 永远 | sizeof(square) 从 8 变 16 |
| 每个类一张 vtable | 永远 | vtable for square,.rodata 里 48 字节 |
| 每个虚函数都被生成 | 即使从没被调用 | 12,448 字节死代码 (第18.4章) |
| 每次分派一次间接调用 | 每次调用 | 不能内联、不能常量折叠、不能跨它向量化 |
| 每个类若干 RTTI 对象 | 永远 | 3 个类共 6 个 type-info 符号 (第18.5章) |
__cxa_pure_virtual 及其运行时 |
任何一个纯虚函数 | 把 std::terminate、demangler、fwrite、
fputs、abort 都拉进来 |
只有一个分派点的程序,这些都不重要。有一千个类型搅在一个插件式层次里的程序, 这些就是 200 KB 二进制和 2 MB 二进制的差别, 是“瞬间启动”和“从磁盘加载自己的类型名”的差别。
5. 那什么时候可以接受 virtual
- 当你在读遗留代码时。这是整个第18章最诚实的答案:
virtual是一个遗留特性。Qt、MFC、COM 和三十年的库都建立在它之上, 你必须能读。 - 当库的 ABI 要求它时。如果平台递给你一个带虚函数的
struct(COM、某些驱动模型),实现它;不要用它做设计。 - 当实现集合固定、封闭、且很少,而且对象都活在栈上时。
这时
virtual就是一个语法更漂亮的 switch —— 而::std::variant加::std::visit能做同样的事, 而且根本没有 vptr。 - 绝不在你正在设计的新抽象里用它。那种情况用函数指针表
(第18.6章):
同样的分派机制,但有三个好处:你看得见它、你能把它实例化成常量、
而且它不会带来 RTTI、
virtualABI 规则和__cxa_pure_virtual。
6. 经验法则
- 写
virtual之前,先把类型列表写下来。如果列表固定且已知, 你要的是标签、variant或switch,不是 vtable。 - 如果列表是开放的(插件、用户提供的回调),你要的是一张你自己拥有的表,
这样你能给它定版本、把它放进
.rodata, 并让它成为你自己 ABI 的一部分,而不是 C++ 的 ABI。 - 永远不要“以防万一”把类型做成多态的。那 8 个字节是这个错误里最便宜的部分。
- 如果必须为分派而继承,让层次浅一些,所有东西都标注 (第18.3章), 并且永远不要让基类指针逃进你自己的 API。