Skip to content

虚函数与去虚拟化:别急着把虚函数改模板

「虚函数慢」是 vol6 的主场命题之一

Carruth 在 There Are No Zero-Cost Abstractions 里拎出来的命题「没有零开销抽象」,虚函数是最常被点名的那个。它的开销来自三处:查 vtable(一次额外内存访问,vtable 可能 cache miss)+ 间接跳转(阻碍流水线,可能被分支预测器猜错)+ 阻碍内联(编译器不知道运行时调谁,不敢跨函数优化)。三条叠起来,一个虚调用确实比直接调用贵。

但本篇的核心信息是反直觉的:「虚函数慢」是上限,不是常态。现代编译器会去虚拟化(devirtualization),很多场景下能把虚调用优化成直接调用甚至内联,开销归零。ch06 这一章是「C++ 抽象的性能成本」的主场,但对虚函数,我们的建议是「先测,别提前改」。这个态度和「虚函数有成本」并不矛盾,成本是有的,但经常不发生。

上手跑一跑:四种调用的真实成本

把 ch04-04 测过的数据拿过来(本机,平均每次 ns):

text
===== 虚函数与去虚拟化 =====
  虚函数(指针,运行时多态):   0.55 ns  ← 查 vtable + 间接跳转,阻碍内联
  final 类(编译器去虚化):    0.54 ns
  直接对象(非指针,常去虚化): 0.23 ns
  CRTP(静态多态,无虚表):    0.22 ns  ← 可内联
  虚函数/CRTP = 2.5x

读这张表要分清「上限」和「常态」:

上限:通过指针/引用的虚调用(0.55 ns)是最贵的。这时编译器看不见运行时类型,老老实实查 vtable + 间接跳转,比 CRTP(0.22 ns)慢 2.5 倍。这就是「虚函数有成本」的硬数字。

常态:很多调用其实被去虚拟化了:

  • 直接对象(非指针/引用)调用(0.23 ns):编译器在 Derived d; d.foo(); 这种场景能看见 d 的确切类型,直接去虚化成普通调用,和 CRTP 一样快
  • final 类/方法:告诉编译器「没有更派生了」,有时能触发去虚化。但注意,我这个例子里 final 和普通虚函数一样快(0.54 ≈ 0.55),不是「final 没去虚化」!-fopt-info-all 显示 final 类(DerivedF)和普通派生(Derived)的虚调用都被 GCC 推测性去虚化了(speculative devirtualization:运行时比对 vtable 项与编译期已知的目标地址,猜中走内联路径)。0.54 ≈ 0.55 的真因是「推测去虚化的运行时比对开销和一次虚调用相当,没省下来」,而不是「final 没生效」。诚实结论:别以为标了 final 就自动快,去虚化能不能省下开销,要看汇编里是不是真变成了直接 call(-S:虚调用是 call [vtable+offset] 间接跳转,完全去虚化是直接 call func)。
  • CRTP(静态多态):把多态推到编译期(模板),没有虚表、能内联,总是最快。但代价是代码膨胀(每个派生类实例化一份)+ 失去运行时多态。

什么时候真要去虚拟化

去虚拟化在以下场景编译器自己会做(你不用动):

  • 直接对象(非指针/引用)调用,且类型在调用点可见。
  • 派生层级 + final 让编译器能证明唯一实现。
  • LTO 开启时,跨翻译单元的类型信息变可见,更多去虚化机会。
  • 单态(monomorphic)热点:profile 反馈显示某个虚调用点 99% 调同一个类型,有些编译器/PGO 能据此去虚化。

所以「我的虚函数有没有被去虚化」要查汇编看(-S:直接调用是 call func,虚调用是 call [vtable+offset] 的间接跳转),别凭猜。

实战建议:别提前 CRTP 化

把上面这些压成一条工作流:

  1. 先用虚函数写清楚(OOP 表达力最好,易维护)。
  2. profile 找热点。如果某个虚调用不在热点,别管它,已经够快。
  3. 真在热点,先查汇编看去虚化了没。去虚化了就完事。
  4. 没去虚化、且是瓶颈,再考虑:final、改直接对象调用、PGO,最后才是 CRTP/模板。

最常见的反模式是「听人说虚函数慢,一上来就把整个类层级 CRTP 化」:代码复杂度暴涨(模板错误信息、代码膨胀),而原本的虚调用可能编译器早就去虚化了,或者根本不在热点。这是「过早优化」在 C++ 抽象成本领域的典型表现。ch04-04 讲 inline 时说过同一个道理:你的工作是「别挡编译器的路 + 测出真瓶颈再精准改」,不是「使劲手写更快的写法」。

边界提醒:虚函数机制(vtable 布局、虚析构、override 语义)归 vol4 类设计;vol6 只讲「它在硬件上跑时贵不贵、怎么不贵」。

一句话收口:虚函数上限成本是 0.55 ns(via 指针),约 2.5 倍于可内联的 CRTP(0.22 ns),来源是 vtable 查表 + 间接跳转 + 阻碍内联;但很多虚调用会被去虚化成直接调用/内联(直接对象、final、LTO、PGO、单态热点),本例 final 通过指针没触发(0.54≈0.55),诚实说明它不保证;实战按「先虚函数写清楚 → profile → 查汇编确认没去虚化 + 是瓶颈 → 才考虑 final/CRTP」走,别提前 CRTP 化,那是过早优化。

下一篇讲异常。它和虚函数一样常被误解为「慢」,而真实的成本模型是「正常路径零成本、异常路径很贵」。

参考资源

  • Piotr Padlewski C++ devirtualization in clang(CppCon 2015 Lightning)——去虚拟化机制,vol10 复用
  • ch04-04 inline、去虚拟化与编译器优化全景(本篇的虚函数数据出处)
  • Agner Fog《Optimizing software in C++》§7 *Virtual functions》。本地
  • 本篇实测代码:code/volumn_codes/vol6-performance/ch04/virtual_devirt.cpp(与 ch04-04 共用)

v0.7.1-2-g3718060 · 3718060 · 2026-07-06