☰
虚函数与虚函数表:一次调用到底跳了几次
2026/10/9 5:27:07 网站建设 项目流程

你写p->speak(),p的静态类型只是基类,运行期却神奇地调到了派生类的版本。这背后不是魔法,是一张藏在对象里的函数指针表。理解它,你才能判断「虚函数到底慢不慢」。

1. 引子:同一句调用,两种结果

// 反例,不要这么写:想靠基类指针拿到派生行为却忘了 virtual,会静默调错版本structAnimal{voidsound()const{/* 基类实现 */}};structDog:Animal{voidsound()const{/* 派生实现 */}};Dog d;Animal*p=&d;p->sound();// 非虚 → 编译期就绑死 Animal::sound,Dog 的版本永远到不了

忘写virtual时,调用在编译期就钉死在静态类型上;加上virtual,才变成运行期查表。差别大到值得专门拆开看。

2. 核心机制:vptr 与 vtable

带虚函数的类,编译器会给它生成一张虚函数表(vtable,virtual table):每个类一份的全局数组,里面是按槽位排好的函数指针。同时,每个对象头部被塞进一个隐藏指针vptr(virtual pointer),指向自己类的 vtable。

这张表还有两个常被忽略的细节。第一,槽位不是只有函数指针:在 Linux / macOS 的 Itanium ABI(gcc / clang 用的那套)里,vptr指向的位置前面通常还放着一个指向type_info的槽位,typeid和dynamic_cast的运行期类型信息(RTTI)就靠它;这也是为什么「关掉 RTTI」和「vtable 布局」会是同一个话题。第二,多继承时对象里可能有多根 vptr、每根指向一张不同的 vtable(每个有虚函数的多态基类子对象各占一张),sizeof也随之变大。所以「一个对象一个 vptr」只在单继承下成立。

单个 Derived 对象的内存布局(64 位) ┌─────────────── Derived 对象 ───────────────┐ │ vptr ──┐ │ <- 8 字节隐藏指针 │ int x │ │ └─────────┼───────────────────────────────────┘ │ ▼ ┌──────── vtable(每类一份,全局)─────────┐ │ slot0: &Derived::speak │ │ slot1: &Derived::~Derived │ │ ... │ └──────────────────────────────────────────┘ 调用 p->speak() 的三步: 1. 取对象首部的 vptr 2. 按偏移在 vtable 找到 speak 所在槽位 3. 间接跳转到该地址(编译期不知道目标是谁)

3. 实测:vptr 占用多少字节

空类按规矩占 1 字节(占位用);一旦有虚函数,对象至少背上那个 vptr:64 位下是 8 字节。

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>classEmpty{};// 空类:1 字节占位classWithVirt{// 带虚函数:背上 vptrpublic:virtual~WithVirt()=default;virtualvoidf(){}};intmain(){std::cout<<"sizeof(Empty) = "<<sizeof(Empty)<<'\n';std::cout<<"sizeof(WithVirt) = "<<sizeof(WithVirt)<<'\n';}
sizeof(Empty) = 1 sizeof(WithVirt) = 8

这 8 字节就是 vptr 的证据:它不在源码里,是编译器替你加的。继承链上每层虚函数不会再加 vptr(基类子类共享同一根指针,vtable 各自一份)。

官方文档:cppreference · 虚函数,vtable/vptr 的实现语义与覆盖规则看这一节。

4. 调用过程:动态绑定 vs 静态绑定

加上virtual后,通过基类指针调用会走查表;静态类型已知时则可直接调用(甚至被内联)。

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>structBase{virtualvoidspeak()const{std::cout<<"Base::speak\n";}};structDerived:Base{voidspeak()constoverride{std::cout<<"Derived::speak\n";}};intmain(){Derived d;d.speak();// 静态类型 Derived → 直接调用,编译期可知Base*p=&d;p->speak();// 动态类型 Derived → 运行期查 vtable}
Derived::speak Derived::speak

两种写法结果一致,但机制不同:第一行是「直接跳转」,第二行是「取 vptr → 查槽位 → 间接跳转」。

5. 开销分析:虚函数真的慢吗

调用方式绑定时机运行时开销能否被内联
非虚 / 静态编译期直接跳转,几乎为零能(跨 TU 也可 LTO)
虚函数 / 动态运行期一次内存间接寻址 + 间接跳转难(除非被去虚拟化)

虚调用贵在三处:① 多一次内存间接寻址(读 vptr、读槽位);②间接跳转难以被 CPU 分支预测命中:这才是真正常见的性能损失来源,缓存 miss 比那点算术贵得多;③ 目标地址编译期未知,编译器不敢内联,连带失去了内联后的一系列优化。

但务实地说:绝大多数业务代码里虚函数的开销可以忽略。一次虚调用大约几个纳秒,远小于你接下来要做的 I/O、锁竞争或内存分配。Core Guidelines 的性能条目反复强调「没有测量就别为性能牺牲清晰的多态设计」。先把代码写对、写清楚,真有热点再用 profiler 定位,而不是提前把所有虚函数换成final/模板。

官方文档:Compiler Explorer:把虚调用和非虚调用贴进去看汇编,能直观看到call *rax这种间接跳转。

6. 完整示例:多态分派的真实样子

一个Shape继承体系,通过基类指针数组统一调用area(),体会动态绑定的价值。

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>structShape{virtual~Shape()=default;virtualdoublearea()const=0;// 纯虚函数:接口};structCircle:Shape{explicitCircle(doubler):r_{r}{}doublearea()constoverride{return3.14159*r_*r_;}private:doubler_;};structSquare:Shape{explicitSquare(doubles):s_{s}{}doublearea()constoverride{returns_*s_;}private:doubles_;};intmain(){Circle c{2.0};Square q{3.0};constShape*shapes[]={&c,&q};for(constShape*s:shapes){std::cout<<"area = "<<s->area()<<'\n';}}
area = 12.5664 area = 9

同样一句s->area(),对数组里不同对象分别跳到了Circle::area和Square::area。这就是 vtable 在运行期替你做的事。没有它,你得手写if/else类型判断或std::variant访问器。

7. 易错点:虚函数在哪些情况下「不虚」

虚函数有一条铁律:动态绑定只发生在「对象已经构造完成」的前提下。围绕这条铁律,实际编码里翻车的场景高度集中在下面几个:

场景实际行为正确做法
构造函数里调虚函数静态绑定到当前正在构造的这一层,不会派发到派生类别在构造里调虚函数;要参数就显式传进基类构造
析构函数里调虚函数同理,绑定到当前正在析构的那一层同上,把清理逻辑拆成非虚的close()
虚函数带默认实参默认实参静态绑定(按静态类型取),函数体动态绑定派生类不要改默认值,或调用时显式传参
派生类override签名写错(漏const、参数类型不同)变成一个新函数,基类版本照旧被调用,静默失效一律加override,让编译器当场报错
基类析构非虚,却用基类指针delete派生对象只析构基类子对象,未定义行为多态基类用public+virtual析构
派生类声明同名函数把基类所有同名重载都隐藏了,这不是覆盖需要保留就写using Base::f;

前两条最值得动手验证一遍:

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<string>structBase{Base(){std::cout<<"Base() 里调 name() -> "<<name()<<'\n';}virtual~Base()=default;virtualstd::stringname()const{return"Base";}};structDerived:Base{Derived(){std::cout<<"Derived() 里调 name() -> "<<name()<<'\n';}std::stringname()constoverride{return"Derived";}};intmain(){constDerived d;std::cout<<"构造完成后调 name() -> "<<d.name()<<'\n';}
Base() 里调 name() -> Base Derived() 里调 name() -> Derived 构造完成后调 name() -> Derived

Base()里那一行打出的是Base而不是Derived,尽管真正被创建的对象是Derived。原因就在 vptr 的赋值时机:构造基类子对象时,vptr 先被设成基类的 vtable;只有进入Derived的构造函数体之前,它才被改写为Derived的 vtable。析构方向相反,vptr 逐层退回基类版本。所以「构造/析构期间调虚函数」拿到的永远是当前层的行为。这不是编译器偷懒,而是唯一安全的选择:此时派生类的成员还没构造(或已经销毁),真派发过去就会读到未初始化的数据。

8. 去虚化:final 与「把虚调用还原成直接调用」

编译器并不总得老老实实查表。只要它能证明「这次调用的目标唯一」,就会把虚调用**去虚化(devirtualization)**成一次直接调用,进而允许内联:

去虚化的三条常见来源 ① 编译期已知动态类型:局部对象 d.speak()、对象是 final 类 ② 类或函数标了 final:编译器确定不会再有派生类来覆盖 ③ LTO / 整程序优化:跨 TU 看到全程序里只有一个实现 去虚化成功 → 直接调用 → 可内联 → 常量传播、消除冗余查表 去虚化失败 → 取 vptr → 查槽位 → 间接跳转(分支预测的敌人)

final是这里最实用的一把钥匙,它同时干两件事:告诉人和告诉编译器。

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>structWidget{virtual~Widget()=default;virtualintcost()const{return1;}};// final 明确声明「到此为止」:编译器可以放心把 FastWidget 上的调用去虚化structFastWidgetfinal:Widget{intcost()constoverride{return2;}};intmain(){constFastWidget w;constWidget&ref=w;// 静态类型是基类std::cout<<"w.cost() = "<<w.cost()<<'\n';// 动态类型已知,可去虚化std::cout<<"ref.cost() = "<<ref.cost()<<'\n';// 只能走 vtable}
w.cost() = 2 ref.cost() = 2

两行输出一模一样。final从不改变程序的结果,它改变的是生成的代码:w.cost()的动态类型在编译期就确定,可以还原成直接调用并内联;ref.cost()的静态类型是基类引用,只能老实取 vptr、查槽位。结果相同、代价不同,这正是「不要拿可读性换性能」原则适用的地方:真想确认省下了什么,去 Compiler Explorer 看汇编,而不是靠猜。

需要留意的是,final关闭的是「继续派生」这条后路,把它标在一个本来会被继承扩展的类上,等于把设计锁死;反过来,标在工具类、helper、Impl实现类这类「确定不会再有人继承」的类型上几乎零成本。所以合理的做法不是「为了性能到处标final」,而是先测量:真在 profile 里看到间接跳转造成的分支预测失败,再回到热点类型上考虑final,或者干脆换成模板 /std::variant的静态分派。

官方文档:Compiler Explorer:把虚调用、final后的调用各自的汇编贴进去,能直接看到call *rax与直接call的差别。

9. 延伸阅读

  • cppreference · 虚函数:覆盖、纯虚、override/final 的精确语义。
  • Compiler Explorer:对比虚调用与非虚调用的汇编,看清间接跳转的代价。
  • C++ Core Guidelines(总览):性能条目提醒「先测量、别过早为性能牺牲多态清晰度」。

本知识库内的相关篇目:

  • 《虚析构函数:不加会发生什么》 —— 通过基类指针 delete 派生对象时
  • 《对象切片(object slicing):多态失效的隐形杀手》 —— 把派生类对象按值赋给基类对象、按值传给基类参数、塞进 std::vector
  • 《多重继承与菱形继承:虚继承到底解决了什么》 —— 一个类继承两个基类

10. 一句话总结

虚函数靠每个对象 8 字节的 vptr 指向每类一份的 vtable,一次调用要「取指针→查槽位→间接跳」,开销来自间接寻址和分支预测失败;但业务代码里它通常微不足道,先把设计写清楚,别在没测量的地方提前放弃清晰的多态。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询