第18.2章 virtual 究竟是什么

English

1. 三件事,按顺序

virtual 函数不是魔法。它是三个机械事实:

  1. 多态类的每个对象都带一个隐藏指针(vptr), 指向它所在类的函数指针表。所以这种对象不可能比一个指针更小, 而且至少按 alignof(void*) 对齐。
  2. 每个多态类在只读数据里有一张表(vtable), 每个虚函数一个槽位,按声明顺序排列。它由编译器生成;你从来不用写,也改不了。
  3. 虚调用是:读 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

6. 经验法则