ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

虚函数与虚表:多态的成本到底花在哪

虚函数与虚表:多态的成本到底花在哪 ① 钩子同样一行b-f()三种命运同样一行b-f()编译器有时直接跳转有时绕两个弯有时还会自作聪明地在调用前先检查一遍虚表。多态的代价到底花在哪答案全在这几行汇编里。这一集我们要回答三个问题虚调用在机器码层面到底做了什么取 vptr → 查表 → 间接跳转这个动态分派到底贵在哪两次内存读 间接跳转的分支预测失败编译器有哪些去虚化手段能让虚调用变回普通调用② 先看概念vptr 与 vtablevtable虚表每个带虚函数的类一张表表里按声明顺序存着各虚函数的地址。Base一张、Derived一张覆盖的f指向Derived::f。vptr虚表指针每个带虚函数的对象开头藏一个指针指向它所属类的 vtable。对象 → vptr → vtable → 函数地址就是虚调用的完整路径。内存布局Base对象偏移 0 vptr8 字节→ 指向 Base 的 vtable 偏移 8 int x 偏移 12 填充③ 源码 vs 汇编对照structBase{virtualvoidf(){}virtualvoidg(){}intx;// 布局vptr(8B) x(4B) 对齐填充};structDerived:Base{voidf()override{}};structDerivedFinalfinal:Base{voidf()override{}};voidcall_base(Base*b){b-f();}// ① 普通虚调用voidcall_g(Base*b){b-g();}// ② 未被重写 → 投机去虚化voidcall_final(DerivedFinal*d){d-f();}// ③ final → 完全去虚化-O2编译结果; ① call_base两次间接寻址 movq (%rcx), %rax ; 对象首 8 字节 vptr → vtable 地址 jmp *(%rax) ; 间接跳转到 vtable[0] f 的地址 ; ② call_gGCC 生成了投机去虚化检查 leaq Base::g(%rip), %rdx ; 预取 Base::g 的地址 movq (%rcx), %rax ; vptr movq 8(%rax), %rax ; vtable[1] g 的槽位 cmpq %rdx, %rax ; 槽位 Base::g ? jne .L6 ret ; 是 → 直接返回Base::g 是空函数 .L6: jmp *%rax ; 否 → 才走间接跳转 ; ③ call_final整个调用被优化没了 ret ; final 让编译器证明实际类型 → 内联成 ret④ 逐行拆解三种形态① 普通虚调用两次间接寻址movq (%rcx), %rax ; ① 从对象取出 vptr对象首 8 字节 jmp *(%rax) ; ② 从 vtable 取出函数地址并跳转这条路径叫动态分派dynamic dispatch对象内存里的 vptr 告诉你这个对象实际是哪个类的vtable 告诉你该类里 f 的地址在哪间接跳转jmp *(%rax)跳到真正的函数。编译器不知道b指向的是Base还是Derived所以必须运行时查表。这是多态能工作的地基也是它的成本来源。② 投机去虚化编译器在赌call_g里 GCC 生成了这样一段leaq Base::g(%rip), %rdx ; 预取如果没被重写就该是 Base::g movq (%rcx), %rax ; vptr movq 8(%rax), %rax ; vtable[1]g 的槽位 cmpq %rdx, %rax ; 槽位 Base::g ? jne .L6 ; 不是 → 兜底走间接跳转 ret ; 是 → 直接调 Base::g甚至内联它的推理是本编译单元里没有任何类重写g那么g的槽位十有八九还是Base::g。于是先生成一个快速路径如果槽位正好是Base::g直接调用它这里是空函数直接ret只有被猜中时才走慢速间接跳转。猜对省钱猜错多花一次比较——在大多数调用都是基础实现的场景下这是净赚。这是 GCC 对虚调用做的真实优化市面上极少有人讲。③ final完全去虚化DerivedFinal声明了final编译器能证明这个指针的实际类型只能是DerivedFinal——于是虚调用退化成普通调用甚至因为函数体是空而被内联成一行ret。**去虚化devirtualization**的威力在此。⑤ 为什么这么设计vtable 是每类一张的函数指针表每个带虚函数的对象开头放一个vptr指向它。编译器不知道对象真实类型只能取 vptr → 查表 → 间接跳。这就是动态分派也是多态能工作的地基。代价有三个① 多两次内存读对象→vptr、vtable→函数地址② 间接跳转让 CPU 分支预测器猜不准可能白干几十个周期③ vtable 是独立内存冷调用会缓存未命中。②是白送的彩蛋本编译单元里没有类重写gGCC 判断调用 g 十有八九就是 Base::g于是生成带检查的投机版本——猜中走捷径猜错才兜底。这是编译器对虚调用做的真实优化市面上几乎没人讲过。③是去虚化DerivedFinal加了final编译器能证明实际类型虚调用退化成普通调用甚至内联成一行ret。⑥ 深入vptr 在构造期间怎么变E07 预告过构造期调用虚函数不会多态现在看 vptr 的具体赋值时机构造Derived对象时编译器生成的代码大致是Base的构造函数把 vptr 设为Base的 vtable 地址再执行Base的构造体回到Derived的构造把 vptr改写成Derived的 vtable 地址再执行Derived的构造体。所以反汇编一个派生类构造函数你能看到vptr 被写入两次每次指向不同的 vtable。这就是构造期间调虚函数解析到当前类的机器证据。析构相反先指向Derived的 vtable析构派生部分切回Base的 vtable析构基类部分。⑦ 常见误区误区 1“虚函数比普通函数慢一个固定倍数”不是。-O2下很多虚调用被去虚化/投机去虚化可能和普通调用一样快真正的开销只在无法证明实际类型的热点路径。误区 2“每个对象都存一份虚表”错。vtable 每类一张全局只读数据E04 讲过在.rdata对象里只有 8 字节 vptr。误区 3“final只是给人看的”它是给编译器看的——final让去虚化成为可能这是有真实性能收益的。误区 4“虚析构可有可无”通过基类指针delete一个派生对象时若析构不是虚的只调用基类析构 → 派生部分泄漏。有虚函数就几乎必然需要虚析构。误区 5“多态对象布局和普通对象一样”多了 8 字节 vptrsizeof变化、offsetof失效、不能当 POD 序列化。对象里第一个字段就是 vptr你声明的第一个数据成员实际排在 vptr 之后偏移 8 起。⑧ 实战启示热点循环里少用虚调用间接跳转 分支预测失败在高频路径上是实打实的开销。想关掉多态就用 final不给派生机会编译器才敢去虚化。选对工具箱编译期多态用模板/CRTP运行期多态才用虚函数——两者不是一回事。别忘了隐藏成本一个虚函数让对象多 8 字节 vptr且析构/拷贝虚化的复杂性随之而来。把虚析构当成默认任何作为基类使用的类析构函数都该是virtual的。⑨ 扩展专题一去虚化的另外两种手段除了final编译器还能通过另外两条路去虚化类型窄化narrowingDerived d; d.f();——这里d是具体类型的对象不是指针/引用编译器知道它只能是Derived直接静态调用Derived::f连 vtable 都不查。这是最彻底、最常见的去虚化。整个程序分析LTO开启链接期优化后编译器能看到整个程序里没有别的类重写它于是把虚调用改静态。这解释了为什么开 LTO 后多态代码可能变快。一个实践对照voidvia_ptr(Base*b){b-f();}// 可能保留虚调用voidvia_obj(Derived d){d.f();}// 编译器直接静态调用零虚成本类型越具体编译器越敢优化。这也解释了为什么接口里用引用/指针、具体实现用具体类型是性能友好的模式。⑩ 扩展专题二接口继承的成本全景用接口纯虚基类做抽象时别忘了一个抽象层级带来的成本链对象体积 8B vptr 构造 设置 vptr 的指令可能多次 虚调用 2 次内存读 1 次间接跳转若没被去虚化 虚析构 沿继承链的虚分发对比模板多态CRTP对象体积 0无 vptr 虚调用 静态解析可内联 类型约束 编译期绑定无运行时多态选择标准运行期多态运行时才知道类型、异构容器、插件用虚函数编译期就能确定的算法族用模板/CRTP。两者的汇编真相截然不同一个靠 vtable 间接跳一个靠模板展开直接内联。⑪ 扩展 FAQQvtable 里的槽位顺序是什么A按虚函数在类里的声明顺序第一个虚函数在槽位 0。继承时基类的虚函数槽位排前面派生类新增的排后面E09 会看到多重继承时多个 vtable 的槽位规则。Q-fno-devirtualize会怎样A关闭去虚化所有虚调用都走 vtable——可以拿来对比去虚化到底省了多少。Q为什么call变成了jmpAcall_base是尾调用最后一条语句就是调用并返回编译器把它优化成jmp复用当前栈帧E03 的尾调用优化。虚调用本身也可以是call尾调用形态只是更省。Q虚拟继承和虚函数是一回事吗A不是。虚函数管运行时方法分发本集虚继承管菱形继承下共享基类子对象E09。两者都涉及 vptr/vtable但机制不同——虚继承的表叫虚基类表。Q怎么查一个对象有几个 vptrAsizeof看多了几个指针或-O0反汇编构造函数看 vptr 被写入几次。多重继承两个虚基类就有两个 vptrE09 实测 32 字节。Q-O0和-O2下虚调用的汇编差别大吗A大。-O0下基本保留完整的取 vptr → 查表 → 间接 call能看到教科书形态-O2下可能被投机去虚化、去虚化甚至内联成ret。想看清机制用-O0想看真实性能用-O2。⑫ 扩展实验看 vtableobjdump -s -j .rdata E08_virt.o或反汇编在只读段里找 vtable——你会看到一列函数地址。看 vptr 写入给Derived写构造-O0 -S找movq ...(%rax)写 vptr 的指令确认两次赋值Base 的、Derived 的。关去虚化对比g -O2 -fno-devirtualize -S E08_virt.cpp与默认对比看call_g/call_final是否变成纯间接跳转。尾调用 vs 普通调用把call_base改成int call2(Base* b){ b-f(); return 1; }非尾调用看jmp是否变成call。接口 vs CRTP 对照写同功能的虚函数版和 CRTP 版-O2 -S对比——一个保留 vtable 间接跳一个全内联。⑭ 扩展专题三虚调用与 CPU 分支预测的恩怨为什么间接跳转比普通跳转贵因为 CPU 的分支预测器擅长预测规律性分支循环往回跳、if 大部分走同一侧但不擅长预测目标地址未知的间接跳转。普通call Base::f目标地址是编译期常量CPU 可以预取指令流水线不停。jmp *(%rax)目标地址要等rax算出来才知道CPU 只能猜通常猜和上次一样。如果虚调用在循环里每次都指向同一实现常见预测器很快学会但如果交替调用不同实现多态对象数组轮流调预测器反复猜错——每次猜错流水线要冲刷重来代价可达几十个周期。所以虚调用慢的真正大头不是那两次内存读而是间接跳转对分支预测的破坏。这解释了为什么虚调用在热点循环里才需要担心单次调用看不出差循环里反复触发才累计为什么多态对象数组轮流调虚函数是性能毒药预测器永远猜不对。实战如果有一个类型标签 switch就能解决的场景用std::variantvisit编译期生成跳转表往往比虚调用更快——因为它把运行时类型分发变成可预测的分支。⑮ 扩展专题四std::function与虚调用的关系E11 会细讲std::function这里先剧透它和虚调用的血缘关系std::function的类型擦除本质上就是一个隐藏的 vtable。voidcall_it(conststd::functionvoid()fn){fn();}std::function内部有一个函数指针 控制块结构含类型擦除表即隐藏 vtable调用fn()时编译器做间接调用——和虚调用的jmp *(%rax)是同一类成本E11 会看到call *24(%rcx)的实测汇编差别在于std::function连类型都擦除了代价比虚调用更高多一层间接 可能的堆分配。血缘总结虚函数运行期方法分发、std::function运行期可调用对象分发、虚拟继承运行期共享子对象定位——三者都靠隐藏指针 间接访问只是在不同的抽象层。理解了虚表你就理解了它们的一半。⑯ 扩展专题五多态与序列化/拷贝的坑对象带 vptr 后很多想当然的操作变得危险memcpy整个对象会把 vptr 一起拷走。如果源对象和目标的真实类型一致碰巧能用不一致或对象有内部指针就炸。offsetof/直接按偏移访问成员vptr 占了开头 8 字节所有成员偏移都 8且布局非标准E06 讲过is_standard_layout。浅拷贝 虚析构Base* p new Derived; Base q *p;——这调用Base的拷贝构造切片slicingq只有Base部分虚调用也变成Base的。多态对象不能按值拷贝。序列化多态对象不能直接写内存要用虚函数serialize/deserialize递归序列化每个具体类型。核心心法多态对象用指针/引用传递用虚函数操作别按值拷贝、别 memcpy、别当字节数组。这条规则的全部理由都能在本集和 E06/E07 的汇编里找到。⑰ 扩展 FAQ第二轮Q虚函数表的槽位顺序会不会变A会随新增虚函数、覆盖、继承顺序变化是 ABI 的一部分E23 会讲。两个编译器编译同一继承结构vtable 布局应一致但别假设细节。Q纯虚函数0在表里是什么A槽位指向一个纯虚调用错误处理函数__cxa_pure_virtual之类。调它通常是 bug在未完成构造的对象上调用纯虚函数。Qfinal加到成员函数和加到类有什么区别Afinal成员函数阻止进一步 override对该槽位的去虚化有利final类阻止任何继承让任何该类型引用都是最终类型去虚化更彻底。Q为什么虚析构要noexceptA析构默认 noexcept。如果虚析构可能抛且 delete 时再抛会std::terminate。基类虚析构设计成不抛是最佳实践。Q-O2下call_base一定能去虚化吗A不一定。call_base(Base*)接收任意 Base 指针可能有外部传入的派生类对象本编译单元看不到全部派生类通常保留虚调用。去虚化依赖final、具体类型对象、或 LTO 的全局视野。⑱ 扩展实验第二轮交替多态实测两个不同派生类的对象数组轮流调虚函数 vs 同一类对象数组测吞吐差——验证分支预测失败的代价。std::function对照把虚调用换成std::function再测对比两种间接调用的成本差E11 会有汇编证据。切片实验Base b *p;p 指向 Derivedb.f()输出 Base 版本——亲眼确认切片。memcpy多态对象复制一个含 vptr 的对象再调用虚函数观察行为可能正常、可能崩——UB。纯虚调用在基类构造函数里调用纯虚函数运行看__cxa_pure_virtual报错。⑳ 扩展专题六vtable 在内存里的长什么样把 vtable 当数据结构看它大概是这样的以Base为例偏移 内容 0 typeinfo 指针指向 RTTI 类型信息E10 会用到 8 offset_to_top用于多重继承定位E09 详述 16 Base::f 的地址 ← 虚函数槽位 0 24 Base::g 的地址 ← 虚函数槽位 1 ... 派生类新增虚函数依次排后关键细节表头不只是函数vtable 前几个槽位还存着 RTTI 指针和到顶偏移它们服务于dynamic_cast/typeidE10和多重继承E09。对象里的 vptr 指向 vtable 的第一个虚函数槽位偏移 16 处而不是表头——所以movq (%rax)取到的就是第一个虚函数地址。vtable 在只读段.rdata/.rodata它是程序启动时就定好的静态数据不属于任何对象对象只有 vptr 指向它。反汇编验证objdump -s -j .rdata找 vtable 段你会看到一排排地址函数指针前面几个槽位是 RTTI 相关数据。这印证了 E04 讲的vtable 住在只读段。㉑ 扩展专题七虚调用与普通调用的完整成本对照表维度普通调用虚调用未去虚化指令数1 条call取 vptr 查表 间接跳转3~4 条内存访问0或参数搬运2 次vptr 槽位分支预测可预测间接跳转易猜错可内联是通常不行除非去虚化编译器优化空间大小这也解释了为什么性能敏感的多态应该尽量用具体类型对象编译器直接静态调用需要抽象时用final让编译器敢去虚化或者换一种多态std::variantvisit跳转表、模板/CRTP编译期展开、策略模式模板参数。核心判断如果你的多态在编译期就能确定就不该付出运行期动态分派的成本。汇编的价值就在于让你看清楚这个抽象到底花了多少条指令。㉒ 扩展实验第三轮看 vtable 表头objdump -s -j .rdata E08_virt.exe定位 vtable辨认 RTTI 指针和虚函数地址的排布。RTTI 联动对带虚函数的对象执行typeid(*b)E10 会细讲反汇编看它如何通过 vtable 的 typeinfo 槽位拿类型名。三种多态对照同一功能分别用虚函数、std::variantvisit、模板实现-O2 -S对比分发代码的指令数。LTO 去虚化如果编译器支持把两个翻译单元一个定义派生类、一个调用虚函数用-O2 -flto链接观察调用是否被去虚化。㉓ 悬念一个对象只有一个 vptr 还算仁慈——如果你多重继承两个带虚函数的基类对象里会出现两个 vptr地址还得掰弯。
返回列表