虚函数与去虚拟化:别急着把虚函数改模板
「虚函数慢」是 vol6 的主场命题之一
Carruth 在 There Are No Zero-Cost Abstractions 里拎出来的命题「没有零开销抽象」,虚函数是最常被点名的那个。它的开销来自三处:查 vtable(一次额外内存访问,vtable 可能 cache miss)+ 间接跳转(阻碍流水线,可能被分支预测器猜错)+ 阻碍内联(编译器不知道运行时调谁,不敢跨函数优化)。三条叠起来,一个虚调用确实比直接调用贵。
但本篇的核心信息是反直觉的:「虚函数慢」是上限,不是常态。现代编译器会去虚拟化(devirtualization),很多场景下能把虚调用优化成直接调用甚至内联,开销归零。ch06 这一章是「C++ 抽象的性能成本」的主场,但对虚函数,我们的建议是「先测,别提前改」。这个态度和「虚函数有成本」并不矛盾,成本是有的,但经常不发生。
上手跑一跑:四种调用的真实成本
把 ch04-04 测过的数据拿过来(本机,平均每次 ns):
===== 虚函数与去虚拟化 =====
虚函数(指针,运行时多态): 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 化
把上面这些压成一条工作流:
- 先用虚函数写清楚(OOP 表达力最好,易维护)。
- profile 找热点。如果某个虚调用不在热点,别管它,已经够快。
- 真在热点,先查汇编看去虚化了没。去虚化了就完事。
- 没去虚化、且是瓶颈,再考虑:
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 共用)