第18.4章 死虚函数膨胀

English

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)

读这三行:

在有五十个类、每个类十来个虚函数的插件架构里,这就是 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. 经验法则