
你写p-speak()p的静态类型只是基类运行期却神奇地调到了派生类的版本。这背后不是魔法是一张藏在对象里的函数指针表。理解它你才能判断「虚函数到底慢不慢」。1. 引子同一句调用两种结果// 反例不要这么写想靠基类指针拿到派生行为却忘了 virtual会静默调错版本structAnimal{voidsound()const{/* 基类实现 */}};structDog:Animal{voidsound()const{/* 派生实现 */}};Dog d;Animal*pd;p-sound();// 非虚 → 编译期就绑死 Animal::soundDog 的版本永远到不了忘写virtual时调用在编译期就钉死在静态类型上加上virtual才变成运行期查表。差别大到值得专门拆开看。2. 核心机制vptr 与 vtable带虚函数的类编译器会给它生成一张虚函数表vtablevirtual table每个类一份的全局数组里面是按槽位排好的函数指针。同时每个对象头部被塞进一个隐藏指针vptrvirtual pointer指向自己类的 vtable。这张表还有两个常被忽略的细节。第一槽位不是只有函数指针在 Linux / macOS 的 Itanium ABIgcc / 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 字节占位用一旦有虚函数对象至少背上那个 vptr64 位下是 8 字节。// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostreamclassEmpty{};// 空类1 字节占位classWithVirt{// 带虚函数背上 vptrpublic:virtual~WithVirt()default;virtualvoidf(){}};intmain(){std::coutsizeof(Empty) sizeof(Empty)\n;std::coutsizeof(WithVirt) sizeof(WithVirt)\n;}sizeof(Empty) 1 sizeof(WithVirt) 8这 8 字节就是 vptr 的证据它不在源码里是编译器替你加的。继承链上每层虚函数不会再加 vptr基类子类共享同一根指针vtable 各自一份。官方文档cppreference · 虚函数vtable/vptr 的实现语义与覆盖规则看这一节。4. 调用过程动态绑定 vs 静态绑定加上virtual后通过基类指针调用会走查表静态类型已知时则可直接调用甚至被内联。// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostreamstructBase{virtualvoidspeak()const{std::coutBase::speak\n;}};structDerived:Base{voidspeak()constoverride{std::coutDerived::speak\n;}};intmain(){Derived d;d.speak();// 静态类型 Derived → 直接调用编译期可知Base*pd;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 -stdc17 -Wall -O2 demo.cpp -o demo#includeiostreamstructShape{virtual~Shape()default;virtualdoublearea()const0;// 纯虚函数接口};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::coutarea 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派生对象只析构基类子对象未定义行为多态基类用publicvirtual析构派生类声明同名函数把基类所有同名重载都隐藏了这不是覆盖需要保留就写using Base::f;前两条最值得动手验证一遍// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestringstructBase{Base(){std::coutBase() 里调 name() - name()\n;}virtual~Base()default;virtualstd::stringname()const{returnBase;}};structDerived:Base{Derived(){std::coutDerived() 里调 name() - name()\n;}std::stringname()constoverride{returnDerived;}};intmain(){constDerived d;std::cout构造完成后调 name() - d.name()\n;}Base() 里调 name() - Base Derived() 里调 name() - Derived 构造完成后调 name() - DerivedBase()里那一行打出的是Base而不是Derived尽管真正被创建的对象是Derived。原因就在 vptr 的赋值时机构造基类子对象时vptr 先被设成基类的 vtable只有进入Derived的构造函数体之前它才被改写为Derived的 vtable。析构方向相反vptr 逐层退回基类版本。所以「构造/析构期间调虚函数」拿到的永远是当前层的行为。这不是编译器偷懒而是唯一安全的选择此时派生类的成员还没构造或已经销毁真派发过去就会读到未初始化的数据。8. 去虚化final 与「把虚调用还原成直接调用」编译器并不总得老老实实查表。只要它能证明「这次调用的目标唯一」就会把虚调用**去虚化devirtualization**成一次直接调用进而允许内联去虚化的三条常见来源 ① 编译期已知动态类型局部对象 d.speak()、对象是 final 类 ② 类或函数标了 final编译器确定不会再有派生类来覆盖 ③ LTO / 整程序优化跨 TU 看到全程序里只有一个实现 去虚化成功 → 直接调用 → 可内联 → 常量传播、消除冗余查表 去虚化失败 → 取 vptr → 查槽位 → 间接跳转分支预测的敌人final是这里最实用的一把钥匙它同时干两件事告诉人和告诉编译器。// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostreamstructWidget{virtual~Widget()default;virtualintcost()const{return1;}};// final 明确声明「到此为止」编译器可以放心把 FastWidget 上的调用去虚化structFastWidgetfinal:Widget{intcost()constoverride{return2;}};intmain(){constFastWidget w;constWidgetrefw;// 静态类型是基类std::coutw.cost() w.cost()\n;// 动态类型已知可去虚化std::coutref.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一次调用要「取指针→查槽位→间接跳」开销来自间接寻址和分支预测失败但业务代码里它通常微不足道先把设计写清楚别在没测量的地方提前放弃清晰的多态。