第18.4章 死虚函数膨胀
1. 编译器不能违反的规则
vtable 必须为类的每一个虚函数准备一个地址。vtable 因为类被多态地使用而生成。 所以每一个虚函数都会被编译进你的二进制,不管有没有东西调用它。
这不是优化失败,这是 ABI:某个你从未见过的翻译单元可能拿到你对象的地址并调用槽位 3,
所以槽位 3 必须指向真实代码。编译器没有权利注意到“你的程序只调用
area(),从不调用 heavy()”。
2. 实测
一个类,一个没人调用的虚函数。编译两次:一次带 virtual,
一次删掉这个关键字、其他什么都不改。
#include <fast_io.h>
struct shape
{
virtual double area() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{};
explicit square(double s) noexcept : side{s} {}
double area() const noexcept override { return side * side; }
virtual void heavy(double x) noexcept
{
x = x * 1.0 + 1.0;
x = x * 1.2 + 2.0;
/* ... twenty-six more lines of the same shape ... */
x = x * 8.2 + 30.0;
side = x;
}
};
int main()
{
try
{
square sq{3.0};
shape &s = sq;
::fast_io::io::println(s.area());
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
heavy() 从未被调用。用 -O2 编译,比较两个二进制的
.text 大小:
base text=39799 heavy_sym=1 (virtual, one-line body)
virt text=40663 heavy_sym=1 (virtual, 30-line body, never called)
novirt text=39775 heavy_sym=0 (same 30-line body, NOT virtual, never called)
读这三行:
virt减novirt= 888 字节死代码, 它们存在的唯一原因就是virtual这个词在场。 对一个没人调用的函数,大约每行代码 30 字节。novirt比base还小,而且符号square::heavy(double)从二进制里彻底消失: 编译器发现从没人调用,就把定义删了。- 把 30 行的函数体换成 400 行再做同一个实验,同样的差值变成 12,448 字节(39,799 → 52,247)。 它随你写的代码线性增长,而不是随你调用的代码。
在有五十个类、每个类十来个虚函数的插件架构里,这就是 300 KB 二进制变成 3 MB 的地方。
这也是嵌入式项目禁止在“长得像数据结构的类”里用
virtual 的原因:你为每个类型的每个对象、永远地为整个接口付钱。
有两种缓解手段,而且都不彻底:-ffunction-sections -Wl,--gc-sections
只有在整张 vtable 都能被丢掉时才可能丢掉一个虚函数,
而 -flto 只能去虚化它能证明目标的调用。
你做不到的是告诉编译器“这个类的 vtable 有 area() 但没有
heavy()”。用你自己建的表
(第18.6章),
你列出你实际用的条目,没用的条目成本为零,因为它们压根不存在。
3. __cxa_pure_virtual:隐藏的运行时依赖
纯虚函数没有函数体,但 vtable 仍然需要一个地址填进它的槽位。
Itanium ABI 用 C++ 运行时提供的名为 __cxa_pure_virtual 的函数来填。
所以只要你声明了一个纯虚函数,你的目标文件就开始索要运行时符号:
struct shape
{
virtual double area() const noexcept = 0;
virtual ~shape() noexcept = default;
};
struct square final : shape
{
double side{2.0};
double area() const noexcept override { return side * side; }
};
int use(shape &s) noexcept { return s.area() > 0.0; }
int main() { square q; return use(q); }
$ g++ -std=c++26 -fherbceptions -O0 -c pv.cc && nm -C -u pv0.o
w __cxa_pure_virtual
U operator delete(void*, unsigned long)
U vtable for __cxxabiv1::__class_type_info
U vtable for __cxxabiv1::__si_class_type_info
四个符号,其中只有一个是关于析构的。另外三个是 RTTI 运行时:
声明一个纯虚函数,就让你的目标文件依赖
__cxxabiv1::__class_type_info 和它的单继承兄弟,
即使你从不写 typeid 或 dynamic_cast。
而 __cxa_pure_virtual 现在也不再是一个只调 abort 的小存根。
从本书工具链的 libstdc++.so.6 反汇编出来:
00000000000b6ded <__cxa_pure_virtual@@CXXABI_1.3>:
b6ded: endbr64
b6df1: push %rax
b6df2: call b2570 <_ZSt9terminatev@plt>
它调用 ::std::terminate,而默认处理器是
__gnu_cxx::__verbose_terminate_handler,它调用 ——
同样从那个库里实测得到:
cbbac: call __cxa_current_exception_type@plt
cbbed: call __cxa_demangle@plt
cbc11: call fwrite@plt
cbc25: call fputs@plt
cbc3f: call fwrite@plt
cbccb: call abort@plt
也就是说:一个纯虚函数会把 std::terminate、C++ demangler 和
stdio 拖进你的程序 —— 而这个程序从不打印任何东西、
从不抛异常、从不使用 RTTI。在 freestanding 目标上,
或者在没有 stdio 的嵌入式构建里,这就是那个没人解释得清的链接错误。
在宿主环境里,它是你 ROM 里的死重。
没有办法退出。你不能用 noexcept 函数体自己提供
__cxa_pure_virtual,你不能要求一张“没有纯槽位”的 vtable,
而 -fno-rtti 也去不掉这个依赖(它删掉的是 typeinfo 对象,
不是纯虚槽位)。
4. ABI 不是你的
C++ 标准没有规定虚分派怎么实现。实际上有两个互不兼容的答案,
Itanium(GCC、Clang、大多数 Unix)和 MSVC,它们在 vtable 布局、vptr 位置、
RTTI 表示、多重继承的 thunk、虚基类,以及调整 this 的转换上都不一样。
所以一个带虚函数的类在一个编译器家族之外没有 ABI,
这就是为什么每个想要稳定接口的大型 C++ 库都导出一个 C API 并把类藏起来。
而你自己定义的函数指针表不是这样:它的布局就是你的 struct 说的那个样子,
在每个编译器里都一样,因为关于它没有任何特殊之处。
5. 继承了一个层次该怎么办
6. 经验法则
- 你写的每个虚函数都会被编译,永远,在每个派生类型的每个对象上。少写一些。
- 永远不要加你不调用的纯虚函数。它是唯一一个为零功能付出运行时依赖 (demangler + stdio)的构造。
- 如果必须有纯虚函数,就接受你的程序从此依赖
__cxa_pure_virtual,并且在 freestanding 或-nostdlib构建失败时不要惊讶。 - 测量你自己的二进制。
size、nm -C和对符号表的grep,是唯一能知道一个层次到底花了你多少钱的办法。 - 永远不要围绕
virtual设计新抽象。 第18.6章的全部意义, 就是你不用付出这些也能拿到分派。