ARTICLE DETAIL

资讯详情

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

【C++入门】面向对象编程 - 07 虚函数调用怎样在运行时找到派生类实现

【C++入门】面向对象编程 - 07 虚函数调用怎样在运行时找到派生类实现 博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站好文推荐【AIAgent项目】从零构建一个代码PRAgent【C入门】面向对象编程 - 01 类不是装字段的盒子【C入门】面向对象编程 - 02 构造函数怎样把对象带进合法状态【C入门】面向对象编程 - 03 析构函数为什么必须和构造顺序反着走【C入门】面向对象编程 - 04 拷贝控制决定两个对象是否共享同一份资源【C入门】面向对象编程 - 05 继承会在派生对象里放进一个基类子对象【C入门】面向对象编程 - 06 对象切片为什么会让派生部分消失同一个函数调用表达式甚至同一条编译后的机器指令在不同的运行时对象上却能执行完全不同的代码。这就是 C 运行时多态的核心能力也是虚函数与普通成员函数的根本区别。让我们先看一个基础的例子#includeiostreamclassShape{public:virtualvoidDraw()const{std::coutDrawing a shape\n;}virtual~Shape()default;};classCircle:publicShape{doubleradius_;public:explicitCircle(doubler):radius_(r){}voidDraw()constoverride{std::coutDrawing a circle, radius radius_\n;}};classRectangle:publicShape{doublewidth_,height_;public:Rectangle(doublew,doubleh):width_(w),height_(h){}voidDraw()constoverride{std::coutDrawing a rectangle, width_ x height_\n;}};voidRender(constShapes){s.Draw();// 同一个调用表达式执行哪个函数}intmain(){Circlec(5.0);Rectangler(3.0,4.0);Render(c);// 输出 Drawing a circle, radius 5Render(r);// 输出 Drawing a rectangle, 3 x 4}在上面的代码中Render函数对应的机器码只有一份编译器也只生成了一次指令。然而两次调用s.Draw()却执行了不同的函数体第一次执行了Circle::Draw第二次则执行了Rectangle::Draw。在编译Render函数时编译器无法预知将来会传入什么具体类型的对象。在编译期唯一确定的是s的静态类型即const Shape。究竟执行哪个函数需要等到运行时根据引用绑定的实际对象类型来决定。这就是虚函数的动态绑定dynamic binding也称为动态分派dynamic dispatch。静态类型决定了能调用什么动态类型决定了调用哪个版本在 C 的类型系统中每个表达式都有两个层面的类型静态类型static type是编译器在编译阶段从声明中确定的类型动态类型dynamic type则是表达式在运行时指向或引用的对象的真实类型。对于非虚函数编译器在编译期就会根据静态类型决定调用哪个版本而对于虚函数当通过基类的指针或引用发起调用时最终执行的将是对应动态类型的版本。Circlec(5.0);Shaperefc;// 静态类型 Shape动态类型 CircleShape*ptrc;// 静态类型 Shape*动态类型 CircleShape valc;// 静态类型 Shape动态类型也是 Shape切片ref.Draw();// 虚调用 → Circle::Draw动态类型参与派发ptr-Draw();// 虚调用 → Circle::Drawval.Draw();// 非虚调用 → Shape::Draw动态类型就是 Shape虽然ref和ptr的静态类型都是基类但它们引用或指向的是完整的Circle对象其动态类型是Circle因此虚调用会找到Circle::Draw。而val是一个独立创建的Shape对象这是对象切片slicing产生的基类副本。它的动态类型就是Shape所以val.Draw()执行的是Shape::Draw。我们需要认清一点并非“通过基类引用或指针调用就一定是虚调用”。当你明确通过对象名加上成员访问运算符来调用虚函数时编译器能够确定其动态类型等于静态类型此时就可以在编译期直接解析目标函数这被称为去虚化devirtualization。这是一种优化手段并没有改变语义。在某些情况下虚函数的调用不会经过动态分派。例如使用作用域限定符如c.Shape::Draw()明确指定要调用基类的版本编译器就会把它当作非虚调用处理。此外在构造函数和析构函数中调用虚函数时也不会分派到更底层派生类的覆盖版本。这不是编译器的优化而是语言标准的明确规定。因为在那个执行上下文中派生类的部分要么还没构造要么已经销毁如果发生分派就会访问未初始化或已失效的内存。override和final让编译器替你验证意图要成功覆盖override基类的虚函数派生类函数的签名必须与基类完全一致。这包含了函数名、参数类型列表不看参数名、const 限定符、引用限定符以及返回类型的协变规则。只要有任何一项不匹配派生类的函数就不能算作覆盖它仅仅是一个与基类虚函数同名的独立函数这可能会导致名字隐藏但绝不会参与虚函数的分派。引入override关键字的目的正是为了消除这种潜在的隐患。在派生类的函数声明后加上override编译器就会主动去检查基类中是否存在匹配的虚函数。如果没有编译就会报错。这种显式的约束比肉眼检查要可靠得多尤其是当基类代码来自第三方库或发生更改时。override能确保所有相关的派生类在编译时就暴露出问题避免了运行时因分派失败而产生final关键字有两种用途加在虚函数之后表示禁止任何更底层的派生类继续覆盖该函数加在类名之后则表示禁止任何类继承它。这两种用法都在向编译器声明继承层次到此为止。这不仅为编译器的去虚化优化创造了条件也在设计层面清晰传达了意图“这是一个最终版本不再为未来的扩展预留接口”。的扩展预留接口”。虚函数的实现直觉vptr 和虚表以上内容探讨的是 C 标准层面关于虚函数的语义规则动态类型如何决定执行目标override如何验证以及何时抑制虚调用。标准本身不规定具体的实现机制只约束最终的行为。但在实际工程中主流的 C 编译器如 GCC、Clang、MSVC大多采用了一套相近的底层方案在对象中植入一个指向虚函数表的指针通过间接调用实现动态分派。下面介绍的模型基于 Itanium C ABI 和 MSVC ABI 的共性。不同平台在指针偏移、虚表槽位等细节上可能有所不同但基本原理相通。对于多态类型即包含虚函数的类型编译器会在其对象布局中隐秘地插入一个指针称为虚表指针vptrvirtual table pointer。这个指针指向与该类型关联的静态数据结构——虚函数表vtablevirtual function table表中存放着该类型所有虚函数的入口地址。每个多态类型拥有一份专属的虚表所有属于该类型的对象共享这同一份虚表数据但每个对象各自持有一个指向它的 vptr。当编译器遇到通过基类引用或指针发起的虚函数调用如s.Draw()时会生成类似如下伪代码的指令序列首先从对象s所在的地址读取 vptrvptr 的相对偏移量在编译期已确定接着从 vptr 指向的虚表中读取第 N 个槽位存放的函数地址N 代表Draw在该虚表中的索引也是编译期确定的最后通过该地址进行间接跳转并将s的地址作为this指针传入。整个过程中没有发生任何运行时的字符串匹配或哈希查找。两次内存读取解引用加上一次间接跳转构成了虚函数调用的核心开销。这与某些脚本语言基于名字匹配的动态分派机制截然不同C 的虚函数调用是基于编译期确定的索引来进行间接跳转的。在 x86-64 汇编中一次虚函数调用大致对应这样两步mov rax, [rdi]读取对象的 vptr然后call [rax offset]从虚表中读取函数地址并执行。相比普通的直接调用仅多了一次内存读取和间接跳转。这也解释了为什么虚函数通常无法被内联编译器在编译阶段无法预知间接跳转的具体目标因此也就无法将目标代码直接展开到调用点。不同类型的对象对应的虚表各不相同。Circle对象的 vptr 指向Circle虚表其中的Draw槽位存放的是Circle::Draw的地址而Rectangle对象的 vptr 则指向Rectangle虚表存放的是Rectangle::Draw的地址。因此尽管s.Draw()这行代码在底层执行了完全相同的“读取 vptr、读取槽位、跳转”操作但由于 vptr 指向的虚表不同最终执行的代码也就不同。这就是运行时多态在底层的完整运作链路对象记录类型信息通过指针类型信息记录函数地址程序顺着这条链路最终找到对应的执行逻辑。这里需要澄清一个常见的误解很多人认为 vptr 位于对象的起始位置是标准规定的。在主流的实现中为了让第一条指令能直接读取 vptr 而无需计算偏移确实通常把它放在偏移量为 0 的位置。但这纯粹是为了优化C 标准根本没有规定 vptr 应该放在哪里甚至没有规定必须使用 vptr。在涉及多重继承或虚继承时一个对象内可能会有多个 vptr 散布在不同的位置以服务于不同的基类视图。必须把语言标准和编译器的实现细节严格区分开来。C 标准要求的是“根据动态类型分派调用”而不是“必须使用虚表”。历史上确实出现过其他实现方式比如基于类型标签和 switch或哈希表映射只不过它们因为性能和通用性问题没有成为主流。本章中提到的“vptr”、“虚表”和“槽位”只是基于主流编译器的讨论并不代表语言标准的规定。从虚函数声明到虚表槽位在多态类型的虚表中虚函数槽位的排列有着固定的规律基类虚表中声明的函数按顺序占据特定槽位。当派生类重写某个虚函数时新的函数地址会直接覆盖在派生类虚表的同一槽位上而不是新增一个槽位。这种设计保证了无论通过基类还是派生类的引用进行调用该虚函数的槽位索引始终一致。如果派生类新增了基类中没有的虚函数这些函数会被追加到虚表的末尾或者放在派生类专有的区域内。虽然它们存在于虚表中但无法通过基类的指针或引用来调用。这是因为基类的静态类型中并没有这些函数的信息编译器自然不会生成访问这些新槽位的指令。简而言之虚表中“哪些槽位可见”是由调用的静态类型决定的而“槽位里装着哪个函数”是由对象的动态类型决定的二者结合便完成了准确的分派。在多重继承中虚表的结构会复杂得多派生类可能会包含多个 vptr每个基类子对象对应一个。在进行虚函数调用时可能需要对this指针进行偏移调整。编译器会在虚表中插入一些辅助代码如 thunk来确保this指针在进入函数前能指向正确的子对象。多重继承的具体机制相对复杂我们将在专门的章节详细探讨这里主要聚焦于单继承的情况。构造和析构期间的虚调用边界在基类的构造函数和析构函数中调用虚函数时不会发生向派生类的分派。这是 C 标准的一项强制规定。原因很简单在基类构造时派生类的部分还没有初始化如果此时允许调用派生类的虚函数它很可能会访问到尚未初始化的成员从而引发未定义行为。同理当基类析构时注意基类析构发生在派生类析构之后派生类的部分已经被销毁此时再调用派生类函数同样是不安全的。classBase{public:Base(){log();}// 构造期间调用虚函数virtualvoidlog()const{std::coutBase\n;}virtual~Base()default;};classDerived:publicBase{std::string data_;public:Derived(conststd::strings):data_(s){}voidlog()constoverride{std::coutDerived: data_\n;}};Derivedd(hello);// 输出 Base不是 Derived: hello在上面的例子中当执行Base的构造函数时对象还没有真正成为Deriveddata_尚未初始化所以这个瞬间它的动态类型就是Base。在主流编译器的实现中这通过动态更新 vptr 来完成刚进入Base的构造函数时vptr 指向Base的虚表等Base构造完毕vptr 才会更新为Derived的虚表随后再执行Derived的构造函数。析构的过程则正好相反。这种 vptr 逐级切换的机制正是编译器兑现“构造/析构期间不进行向下分派”这一标准承诺的方式。这条规则带给我们的启示是永远不要在基类的构造或析构函数中依赖虚函数去调用派生类的定制逻辑。如果你确实需要这种依赖可以考虑在对象构造完毕后通过单独的初始化函数来触发或者通过参数将必要的信息传递给基类构造函数。偶尔会遇到一种边缘情况基类构造函数的初始化列表中调用了某个间接触发虚函数的表达式。此时规则依然适用虚调用不会进入派生类。在整个基类的构造周期内对象的动态类型都被锁定为当前正在构造的那个类绝不会提前“进化”。虚函数的运行时成本一项实事求是的测算与普通调用相比虚函数调用增加了一次 vptr 读取、一次虚表读取以及一次间接跳转indirect branch。在现代 CPU 架构下两三次内存访问通常只会带来微小的时钟周期延迟而分支预测技术也能很好地处理间接跳转因此这部分开销在绝大多数业务场景下是可以忽略不计的。虚函数真正的性能损耗其实来源于它阻断了编译器的内联优化。由于编译器在编译期无法确定具体执行哪个函数自然也就无法将函数体展开进而失去了常量传播、死代码消除等进一步优化的机会。这种累积效应在性能敏感的核心路径上可能会有所体现。好在编译器通常会在能确定动态类型的上下文中进行去虚化devirtualization优化。在日常开发中只有当性能测试确实证明某个虚函数是瓶颈时才需要考虑优化。常见的策略包括使用final关键字帮助编译器进行去虚化重新设计接口减少虚调用的频率在极端要求下可以使用模板实现静态多态如 CRTP 模式尽管这会增加代码体积和编译时间。对于普通的业务逻辑和 UI 代码而言虚函数的性能消耗往往被 I/O 操作或算法本身的复杂度所掩盖无需过度担忧。此外引入虚函数会增加对象的内存开销主要是 vptr 带来的。在 64 位系统上一个 vptr 占用 8 字节。无论一个类有多少个虚函数每个对象只需承担一个 vptr 的大小。虚表本身是在静态数据区的一份公共数据不随对象复制。当然具体的内存增加量还会受到对齐规则和编译器实现的影响这里的分析仅供建立直观感受。虚函数的工程使用接口先行覆盖可控在 C 工程中虚函数的使用有一条基本准则如果基类声明了一个虚函数就意味着“该操作的具体行为因派生类而异派生类应当提供适合自己的实现”如果某个操作在所有派生类中都是完全一致的那么它就应该是一个普通成员函数这样既能简化结构又能给编译器留下优化空间。对于接口设计纯虚函数如virtual void Draw() const 0;表达的是“这是所有派生类必须履行的契约但基类本身无法提供默认实现”。包含纯虚函数的类就是抽象类abstract class它无法被直接实例化。这很符合逻辑你可以创建一个具体的“圆”但无法凭空创建一个抽象的“形状”。抽象类存在的价值就在于为一系列具体类型制定统一的交互标准。在设计覆盖override时保持虚函数行为的可预测性至关重要在重写虚函数时绝不能收紧原有的语义约定即不能违反里氏替换原则。如果派生类在覆盖某个函数时发现基类的语义不适用那通常意味着不应该使用继承可以考虑组合或者基类的接口设计得过于宽泛需要拆分成更细粒度的函数。虚函数是 C 面向对象编程中最具辨识度的特性之一但它绝不是银弹。你可以写出充满虚函数却毫无抽象边界的代码也可以通过值类型和模板实现出色的多态架构。虚函数的最佳应用场景是一套接口在运行时可能有多种实现调用方不需要关心具体是哪种类型只需根据实际类型动态执行对应逻辑。如果你发现代码里充斥着一堆基于类型标签的if或switch分支通常是引入虚函数的好时机反之如果继承层次深达五层每个具体类只重写零星的一两个函数其他的都在“空转”那说明整个类的架构可能需要重新评估了。在工程实践中一个优秀的虚函数接口应当不言自明函数名清晰传达意图参数明确表达约束返回值毫无歧义且默认实现能为派生类提供坚实的基础。衡量接口好坏的一个简单标准是“如果其他开发者只看这个函数的声明和注释他能准确无误地写出一个派生类的覆盖版本吗”如果不行说明接口还需要打磨。最后需要正视的一个现实是跨模块如动态链接库调用虚函数会带来额外的复杂性。如果基类和派生类分属不同的库它们的正常交互高度依赖于双方底层 ABI应用二进制接口的一致性包括 vptr 的位置、虚表的布局等。在 GCC/Clang 生态中通常要求编译器版本和编译选项保持兼容而在 MSVC 生态中更建议基类在同一模块内定义和实例化。这是因为多态对象的身份信息被分散在不同的编译单元中而 ABI 就是连接它们的隐形契约。理解了虚函数如何在运行时定位到派生类下一章我们将探讨一个密切相关的问题当通过基类指针销毁一个派生类对象时析构函数的调用链是如何工作的如果析构函数不是虚函数又会导致怎样的灾难性后果码字不易欢迎大家点赞关注评论谢谢
返回列表