ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深挖C++多态:虚函数表、RTTI与dynamic_cast底层实现

深挖C++多态:虚函数表、RTTI与dynamic_cast底层实现 C面试里有个特别有意思的现象每个人都能把“多态三要素——继承、虚函数、父类指针指向子类对象”背得滚瓜烂熟但真要问到“编译器到底往对象里塞了什么”“虚函数表长什么样”“typeid的信息存在哪儿”一半人就卡住了。更别说RTTI这种东西平时写业务代码几乎碰不到可一旦碰上dynamic_cast和typeid很多人只知道“能用”完全不知道“怎么实现的”。这篇东西我计划把这两个机制一起讲透。原因很简单RTTI和虚函数表是长在一块的割裂开讲谁都讲不清。你理解了vtable的内存布局RTTI的底层逻辑就顺带明白了。我会从汇编层和内存布局的角度拆解尽量说人话给代码、给实验、给踩坑经验。1. 虚函数调用的完整路径从源码到汇编1.1 vptr和vtable的内存布局理解多态的第一块基石先明确一个基本事实C的多态不是“天生的”是编译器在背后做了一张表、塞了一个指针换来的。这张表叫虚函数表vtable那个指针叫虚函数指针vptr它通常位于对象内存布局的最前面。来看一个最简单的例子class Base { public: virtual void f() { std::cout Base::f\n; } virtual void g() { std::cout Base::g\n; } void h() { std::cout Base::h\n; } // 非虚函数 }; class Derived : public Base { public: void f() override { std::cout Derived::f\n; } // 覆盖 void h() { std::cout Derived::h\n; } // 隐藏 }; int main() { Derived d; Base b d; b.f(); // 多态输出 Derived::f b.h(); // 非虚编译期定死输出 Base::h }Base对象长什么样它没有数据成员但sizeof(Base)不是1而是864位平台上因为编译器给对象头部塞了一个vptr。vtable大致是这个样子slot 0指向type_info的指针RTTI信息后面细说slot 1Base::fslot 2Base::gDerived对象的内存布局则是vptr指向Derived自己的vtableDerived的vtable里slot 0还是type_info指针指向Derived的type_infoslot 1指向Derived::fslot 2还是Base::g“覆盖”的本质是用派生类的函数地址改写从基类继承来的那个槽位。这个改写在编译期就完成了。也就是说Derived对象在构造完后它vptr指向的vtable里已经写好了“该走哪个函数”的答案运行时只是照着表跳转而已。1.2 从汇编看一次虚函数调用很多人对“虚函数调用比普通函数调用慢”这句话理解不够具体看一段伪汇编就明白了。假设b.f()编译后的代码逻辑是mov rax, [rbp-8] ; 取出b的地址 mov rax, [rax] ; 取出vptr指向vtable mov rbx, [rax8] ; 取出vtable中f的槽位假设偏移8 call rbx ; 间接跳转普通函数调用是call Base::h这种直接地址CPU可以预测得很好。而虚函数调用多了一次间接寻址和跳转分支预测失败时流水线会被冲刷这才是虚函数性能损耗的真正来源。实际上在现代CPU上这种损耗通常只有几纳秒但如果是高频热路径里调用百万次积少成多就相当可观了。除了性能还有个更隐蔽点虚函数无法内联。编译器不知道运行时进来的到底是哪个类型所以b.f()这个调用点没法把函数体展开。这也是为什么有时候你在不同编译选项下看到的性能差异非常明显。1.3 覆盖override和隐藏hide在虚表中的不同表现这个问题我用粗体标注一下覆盖改vtable槽位隐藏不改。覆盖发生在“基类有virtual 派生类写了一个同签名函数”时编译器会让这个派生类函数地址写入派生类vtable的对应槽位。隐藏则完全不同它在语法层面的语义是“派生类的名字遮蔽了基类的名字”和虚函数机制没有任何关系。Derived中的h()隐藏了Base中的h()但Base::h的槽位在vtable里照旧存在没有人去改它。实际工作中我曾经帮人排查过一个诡异bug基类有一个virtual void reset(int)派生类打算“重写”它但写成了void reset(double)。调用p-reset(42)时编译器警告说“隐藏了基类虚函数”。这就是典型的“想覆盖却写成了隐藏”建议开-Woverloaded-virtual或/w14640这类编译告警能在编译期提前揪出来。2. 继承树上vptr和vtable的构建规则多继承的真正难点2.1 单继承下每层子类如何从头构建自己的vtable单继承的vtable构建逻辑比较直观但细节值得仔细说。编译器为每个类生成一份“完整”的vtable它并不继承父类的vtable内存因为vtable是编译期生成的静态数据不是继承来的对象成员而是“复制父类的布局再改写覆盖项”。以第1节的Base和Derived为例生成流程是复制Base的vtable布局也就是两个槽位的顺序Derived覆盖了f所以slot 1改成Derived::fDerived没有覆盖g所以slot 2保留Base::g如果Derived新增了虚函数x则追加到表尾slot 3变成Derived::x。这个“追加到表尾”很关键。它保证了当你用一个Base*去访问Derived对象时即使这个对象实际上是Derived类型编译器也可以放心地从vtable的前几个槽位取函数地址而不会取错。换句话说基类子对象的vptr看到的前N个槽位布局和基类自身的vtable布局完全一致。这是C对象模型能兼容单继承多态的重要前提。这里注意一个细节vptr是“每层继承链”一个而不是“每个类一个”。比如单继承基类和派生类之间只有一条继承链所以Derived里从Base继承来的那部分只保留一个vptr派生类自己新增的虚函数也在这个vptr指向的vtable里追加。2.2 多继承下偏移量与thunk指针修正的艺术多继承才是真正体现C对象模型复杂度的场景。看这个经典结构struct A { virtual void fa(); int a; }; struct B { virtual void fb(); int b; }; struct C : A, B { void fa() override; void fb() override; int c; };C的对象布局很典型偏移0处A部分含vptr_A后跟成员a偏移16处假设int 4字节对齐B部分含vptr_B后跟成员b最后成员cC对象的地址和A子对象的地址相同但C*转成B*时需要加上偏移量16才能让指针指向B子对象的起始位置。那这里的多态怎么工作呢当你用B* pb指向一个C对象并调用pb-fb()时编译器只知道pb指向的是B子对象它会把pb当作一个B*通过vptr_B查到vtable。问题来了C覆盖了fb所以vptr_B指向的vtable中fb槽位里存放的是C::fb还是某种经过调整的地址假设直接放C::fb那当fb()函数被调用时this指针会被传成pb指向的B子对象偏移处也就是多算了16个字节的地址。C::fb内部访问成员c时实际会越界。编译器解决这个问题的方案叫thunk跳板函数。它在vtable槽位里放的不一定是终极函数地址而可能是一小段机器码sub rdi, 16 ; 调整this指针从B子对象位置回退到C对象头部 jmp C::fb ; 再跳到真正的C::fb你去看多继承下的vtable里面会看到很多这种thunk地址不是直接指向最终函数。这也是为什么调试器里看多继承虚函数的调用栈时偶尔会出现看起来“多了一层”的跳转。理解了thunk这个现象就不奇怪了。2.3 虚继承多态和虚继承叠加时vptr的数量变化虚继承引入的是“共享基类子对象”的概念。菱形继承里最底层的Derived只有一个A子对象但这个A子对象位于Derived对象布局的末尾而不是开头。这样编译器无法在编译期通过固定偏移来找到A子对象需要在对象里额外保存一个偏移量vbase offset或在vtable里记录这个偏移。虚继承多态会让情况更复杂Derived可能会有多个vptr——不仅仅对应每个继承链虚基类相关还需要额外的偏移信息。MSVC和Itanium C ABI在具体布局上实现不同但核心思想都是在vtable的某些槽位里存放“到虚基类子对象的偏移值”运行位置据此找到共享的虚基类。这部分我建议你实际写个菱形继承的代码用调试器看内存布局比看十篇文章都管用。我第一次看的时候也很晕后来发现只要记住一个原则虚基类是为了消除歧义所有虚继承的路径共享同一个子对象这个子对象只能放在末尾靠偏移而不是固定位置访问。3. typeid与type_infoRTTI的地基3.1 type_info是全局唯一的静态身份标识RTTIRun-Time Type Information运行时类型信息中最基础的部分就是typeid运算符。它返回一个const std::type_info引用。std::type_info是个定义在typeinfo头文件里的类有name()、operator、operator!、before()等方法但没有公开的构造函数和拷贝构造函数——这意味着type_info对象是编译器在幕后帮你生成的全局唯一静态实例你没法自己创建。关键细节同一个类型的所有实例取到的type_info地址相同。也就是说Base* p1 new Derived; Base* p2 new Derived; typeid(*p1) typeid(*p2); // true同一个静态对象这个特性在工程上很有用。很多项目会在极热路径上比较typeid(*a) typeid(*b)而不是用typeid(*a) typeid(*b)因为后者可能涉及隐藏的字符串比较极端情况下前者是纯指针比较一条指令的事。不过这种优化依赖编译器将type_info对象去重这在Itanium C ABI和MSVC下都有保证但标准并没有强制规定。实际项目里这么干的很多我在几个性能敏感的库里都见过这种写法。3.2 typeid的两种解析路径编译期解析和运行时动态查表typeid依据操作数的类型走完全不同的两条路第一种情况操作数是多态类型的左值或引用多态指类含有虚函数。编译器无法在编译期确定实际类型于是生成代码在运行时从vptr查到type_info指针。它在vtable中的存放位置是固定槽位——Itanium C ABI中vtable的负偏移处存放type_info指针MSVC则通过第一个vftable槽位指向一个RTTI Complete Object Locator结构再间接取得type_info。第二种情况操作数是非多态类或基本类型。编译器在编译期就能确定类型直接生成对静态type_info对象的引用运行时零开销。所以typeid(int)这种表达式是完全可以出现在常量表达式位置上的但typeid(*p)当*p是多态类型时就不行——它必须运行时才能知道。这也是一个常见的面试陷阱typeid(*p)p是Base*但Base没有虚函数——结果如何答案是这个typeid表达式在编译期就基于静态类型Base决定了返回的是Base的type_info。只有当Base是多态类型时typeid才会真正“查表”。这个区别特别容易让人踩坑。3.3 type_info::name()返回的名称为什么不能直接拿来比较我见过很多新手写这样的代码if (strcmp(typeid(*obj).name(), MyClass) 0) { ... }这是非常危险的。因为type_info::name()返回的是编译器特定的mangled name符号修饰名在GCC/Clang上是_Z7MyClassv之类的东西MSVC上是?MyClass...。它甚至不保证在不同优化设置下完全一致更不能作为稳定标识来序列化或做跨平台比较。正确做法是直接用type_info::operator来比较类型是否相等。如果要给一个类型一个稳定的标识用于序列化应该自己写一套编译期注册的类型ID机制不要指望C标准能给你一个跨平台统一的type name。这一点在写多线程日志系统、序列化框架时尤其重要。4. dynamic_cast的完整判定路径从提问到判定到指针修正4.1 dynamic_cast沿继承链做类型匹配的内部流程dynamic_cast是多态机制里最“重”的操作也是RTTI最核心的应用。它做的事情是给定一个指向多态对象的指针问一个“这个对象真的是T类型吗能安全转换成T*吗”——然后做出判断如果成立返回正确的指针如果不成立指针转换返回nullptr引用转换抛std::bad_cast。内部实现流程大致如下从源指针取出vptr进而取得该对象完整类型的type_info从type_info出发沿着该类型的继承图遍历查找目标类型如果能找到目标类型说明源对象确实“是一个”目标类型找到后根据目标类型是基类还是派生类计算指针偏移量修正指针返回对应子对象地址。实际编译器实现比这个复杂得多比如MSVC会生成一个__RTDynamicCast辅助函数Itanium ABI则用__dynamic_cast函数配合vtable中的偏移信息。但大致的判定逻辑就是“沿着继承链查表比对”这也是为什么dynamic_cast比你想象中更慢的原因之一它可能遍历的不止一个层级。4.2 cross cast兄弟类型之间的交叉转换dynamic_cast有一个static_cast绝对做不到的能力——交叉转换cross cast。考虑D同时继承A和B你有一个A*指向D对象想拿到B*struct A { virtual ~A() default; }; struct B { virtual ~B() default; }; struct D : A, B {}; A* pa new D; B* pb dynamic_castB*(pa); // 合法且成功 // B* pb static_castB*(pa); // 编译错误static_cast无法跨兄弟转换这里的实现依赖前面讲的thunk和偏移量体系编译器需要从pa的实际类型D出发找到D的继承图中包含B那条路径再计算“D整体偏移到B子对象”的距离修正指针。这种转换的判定成本比向上或向下转换更高因为不仅要检查类型是否匹配还要处理跨分支的偏移计算。4.3 引用转换失败的代价与对象布局边界指针形式的dynamic_cast失败返回nullptr这个很安全但引用形式失败时直接抛异常std::bad_cast。异常的人口径非常昂贵——我实测过在现代编译器和操作系统上抛出并捕获一次异常的开销可能是正常虚函数调用的几百倍。所以如果你在业务逻辑里用dynamic_castT做安全判断等于把一个普通操作升级成了异常控制流操作还要求所有调用链都能正确处理异常这个设计在工程上是很糟糕的。在实际项目里我更推荐这样的策略如果只是判断类型是不是某个类型用typeid比较如果需要做安全的向下转型优先思考能否用虚函数重写解决必须dynamic_cast且失败概率很高时用指针形式先判nullptr再进入逻辑。4.4 编译器如何利用vtable的偏移信息定位完整对象前面提到Itanium ABI下vtable的负偏移区域有两个关键值offset_to_top和type_info指针。offset_to_top记录的是从当前子对象到这个完整对象起始位置的偏移。为什么需要这个因为dynamic_cast必须知道“当前指针指向的完整对象起点在哪”。当指针指向多继承的第二个基类子对象时它并不是完整对象的起点offset_to_top能把指针“拉”回完整对象头部。这就是为什么dynamic_cast能在那么多复杂的布局里准确找到目标——vtable里不仅仅放了函数指针还附带了一套对象布局的地图信息。RTTI和对象模型深度绑定从这里体现得淋漓尽致。5. RTTI与多态在工程项目中的权衡何时该用何时该关5.1 关闭RTTI-fno-rtti后会发生哪些连锁反应很多高性能项目游戏引擎、嵌入式C、部分实时系统会关闭RTTI。关闭后typeid不能用了编译期语法报错dynamic_cast不能用了编译器直接拒绝vtable里不再生成type_info指针和offset_to_top相关信息对象体积略小二进制体积下降多态机制本身不受影响虚函数照常工作。问题在于关闭RTTI后很多人习惯用的“类型向下转换反射”思路就断了。企业级代码里常见替代方案是在基类里手工实现类型标识系统——class Base { public: enum class Type { Base, Derived }; virtual Type type() const { return Type::Base; } protected: virtual ~Base() default; }; class Derived : public Base { public: Type type() const override { return Type::Derived; } };这种方案比RTTI轻量得多一次虚函数调用的开销远小于dynamic_cast的遍历开销。缺点是必须自己维护枚举扩展性差而且没法做到“任意类型的完整反射”。但实际项目中大部分类型判断场景都是固定子系统之间的有限类型集合手工方案足够用了。我参与过的几个服务端项目甚至把RTTI关闭后用这种方式优化了核心模块的hot path提升非常明显。5.2 dynamic_cast的滥用对性能的实际影响如果你维护过一个大型继承体系你肯定见过这样的代码void Process(Base* obj) { if (auto* a dynamic_castA*(obj)) { a-DoThingA(); } else if (auto* b dynamic_castB*(obj)) { b-DoThingB(); } else if (auto* c dynamic_castC*(obj)) { c-DoThingC(); } }这段逻辑本身功能没问题但性能上很糟糕。原因不只是dynamic_cast本身的遍历还在于这种链式判断通常没有做任何缓存或短路每次调用都要从头开始匹配。更关键的是这个模式召示着设计上的问题既然每个类都有共同的接口为什么不用虚函数多态来统一调度有一次我做过一个实验在一个模拟的事件分发场景中一万个不同子类对象每个调用一次Process。用dynamic_cast链式判断的实现平均耗时是用虚函数分发实现的3到4倍。差异在几个毫秒级别看起来不大但放到一天几千万次调用的server上就是非常显著的CPU增长。如果你一定要用dynamic_cast我建议用这个模式减少调用次数在构造或第一次访问时缓存转换结果或者在枚举类型上做一个switch不要反复做同样的转换判断。5.3 多态和RTTI在调试与序列化场景里的价值说了一堆性能代价也讲讲vanilla场景下RTTI真正有价值的地方。我在实际工作中觉得它最有用的两个场景是日志和序列化写日志时如果你有一套复杂的继承体系比如不同类型的事件对象调试器里靠肉眼区分确实很难。但有了typeid可以直接打印出对象的真实类型名称辅助定位问题。我经常写这样的工具函数template typename T std::string TypeName(const T obj) { return typeid(obj).name(); }虽然name()返回的是mangled名不好直接阅读但在日志里搜索关键字定位还是够用的。如果嫌难读可以写一个编译器各个平台的demangle封装GCC/Clang下用__cxa_demangleMSVC一般不需要name()本身就可读性尚可。序列化框架也是RTTI的重度用户。反序列化时需要根据存储的类型标识动态创建对象或者至少动态判断指针的实际类型这种情况下dynamic_cast和typeid提供了最低成本的“运行时多态发现”机制避免了手工维护一大套类型注册表的麻烦。我自己写轻量级配置系统时就用了RTTI来验证配置对象到底是从哪个子类实例来的然后配合虚函数做后续逻辑分发代码干净不少。5.4 对象大小、vtable数量与继承体系设计的联动最后讲一个容易被忽视的经验继承体系越复杂对象体积和vtable数量越容易失控。每个含虚函数的类都会有一个vtable多继承每条继承链都会增加一个vptr。虚继承更是会在对象内存里塞入额外的偏移信息。设计大型继承体系时我一般遵循以下几条原则尽量用组合而非继承尤其避免为了复用代码而随便继承继承层级控制在3层以内超过这个层级dynamic_cast匹配成本和vtable体积都会上升多继承不要图方便完全可以用接口类纯虚类代替——纯虚类的vtable开销并不小但比复杂多继承带来的代码复杂度要小得多如果对象数量动辄百万级优先考虑减少vptr数量用空基类优化Empty Base Optimization配合模板技术。我在做一个图形引擎的资源对象时就是通过把一个深继承树拆成“一个基类若干组合组件”把每个实例的体积从56字节降到了24字节。这个结果让人很震撼虚函数表和RTTI带来的隐形成本往往要实际做优化时才会真正意识到。6. 写在最后的几点实操心得本来想在第5节就收尾但整理这篇文章时脑子里又飘过几个踩过的坑补一段当彩蛋。用调试器查看vtable是我强烈建议做的事情。在VS里断点看对象的_vfptr在GDB里用info vtbl你就能直观地看到那几个槽位里跳动的函数地址远比纯看理论记忆深刻。我第一次看到Derived对象的vtable里type_info槽位指向的确实不是Base时那种“原来如此”的感觉很难描述。RTTI和异常处理在编译器实现上常常共享一些基础设施关闭RTTI和关闭异常-fno-exceptions在很多嵌入式编译器上是打包处理的。如果一个库给自己标了-fno-rtti却还依赖异常链接时会遇到一堆奇怪的错误。你接手别人代码时先看看编译选项别急着骂代码烂。还要提醒一个设计层面的东西dynamic_cast和typeid解决了运行时类型识别问题但代价是让代码变得隐晦、让类型之间的边界变得模糊。如果你发现自己在一个系统里到处都要dynamic_cast才能工作那多半是设计出了问题而不是工具不好。优先考虑虚函数重写、访问者模式、模板替身把类型判断收敛在少数几个边界位置长期维护成本会低很多。这不是教条是我接手过好几个项目后实打实的体会。RTTI和多态这套机制本质上都是C在“保持C的高效”和“提供面向对象便利”之间打的补丁。理解这两个机制不只是为了应付面试八股更是为了在你设计系统时知道每一条虚函数、每一次dynamic_cast背后实际烧了多少CPU、吃掉了多少内存。心中有这个数写出来的代码自然就会“贵气”很多。
返回列表