C++继承与多态进阶 目录一、引言为什么这些知识如此重要二、菱形虚拟继承原理深度剖析2.1 问题起源菱形继承的致命缺陷2.2 虚继承是如何解决问题的2.3 深入内存模型——见证真相的时刻2.4 虚基表指针的统一访问方式2.5 动手验证——写出菱形虚拟继承模型三、虚函数表深入探索——揭开多态的神秘面纱3.1 重温多态与虚函数表的基础3.2 单继承虚函数表——监视窗口骗了你3.3 多继承虚函数表——更复杂的真相3.4 多继承中的 this 指针修正——最精妙的细节3.5 菱形继承与菱形虚拟继承的虚表四、反向迭代器——适配器设计模式的经典运用4.1 SGI-STL 源码框架分析4.2 手把手实现通用反向迭代器4.3 集成到 vector 和 list五、继承和多态高频面试题精讲六、总结与思考一、引言这篇文章将基于 SGI-STL 源码分析带你深入 C 继承与多态的底层世界。文章会从菱形虚拟继承的内存模型讲到单继承和多继承的虚函数表结构力求让你读完后有一种原来如此的通透感。二、菱形虚拟继承原理深度剖析2.1 问题起源菱形继承的致命缺陷在 C 中一个类可以同时继承多个父类这就是多继承。多继承带来了灵活性但也引入了一个经典问题——菱形继承Diamond Inheritance。看下面这个继承体系classPerson{public:string _name;// 姓名};classStudent:publicPerson{protected:int_num;// 学号};classTeacher:publicPerson{protected:int_id;// 职工编号};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;// 主修课程};这个继承体系形成了一个菱形结构Person / \ Student Teacher \ / Assistant问题一二义性AmbiguityAssistant a;a._namepeter;// 编译错误编译器不知道访问哪一个 _name因为Student和Teacher各自继承了一份Person的_name所以在Assistant对象中存在两份_name。编译器无法确定你指的是Student::_name还是Teacher::_name。问题一的半解决——显式指定作用域a.Student::_namexxx;a.Teacher::_nameyyy;但这只是解决了二义性数据冗余的问题依然存在——同一个人的姓名被存了两份这显然不合理。2.2 虚继承是如何解决问题的C 引入了虚继承Virtual Inheritance来解决菱形继承的数据冗余和二义性问题。关键字是virtual// 关键改动Student 和 Teacher 虚继承 PersonclassStudent:virtualpublicPerson{protected:int_num;};classTeacher:virtualpublicPerson{protected:int_id;};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;};核心区别使用虚继承后Person这个虚基类在Assistant对象中只存在一份。此时再访问_name就没有二义性了Assistant a;a._namepeter;// 成功只有一份 _name2.3 深入内存模型——见证真相的时刻重要提示VS 编译器的监视窗口经过了特殊处理它展示的对象模型是美化过的并不是真实的内存布局。要看清真实的内存模型必须借助内存窗口快捷键调试时CtrlAltM,1。为了研究虚继承的底层原理我们使用以下简化版的菱形虚拟继承模型classA{public:int_a;};classB:virtualpublicA{public:int_b;};classC:virtualpublicA{public:int_c;};classD:publicB,publicC{public:int_d;};通过内存窗口观察D对象的真实布局我们发现D 对象内存模型概念图 B 部分: ---------------------- | vbptr_B - 虚基表B | - 指向虚基表存储偏移量 | _b | - B 自己的成员 ---------------------- C 部分: | vbptr_C - 虚基表C | - 指向虚基表存储偏移量 | _c | - C 自己的成员 ---------------------- | _d | - D 自己的成员 ---------------------- A 部分公共的虚基类: | _a | - 只有一份 ----------------------关键发现B和C部分各自包含一个虚基表指针vbptr指向各自的虚基表virtual base table虚基表中存储的是当前部分距离公共虚基类 A 的偏移量公共的虚基类A部分在D对象中只有一份——这就是数据冗余被消除的原因。2.4 虚基表指针的统一访问方式一个非常精妙的设计细节不仅是D对象B自己的对象模型也采用了同样的虚基表访问方式。B b;b._a7;// B 对象访问 _a 同样通过虚基表指针b._b8;D d;d._a3;d._b4;B*p1d;// B 的指针指向 D 对象切片B*p2b;// B 的指针指向 B 对象当我们打开汇编代码观察时会发现p1-_a;// 通过虚基表指针 - 查偏移量 - 访问 _ap2-_a;// 通过虚基表指针 - 查偏移量 - 访问 _a两条语句生成的汇编代码完全相同这就是统一访问方式的精妙之处——无论B的指针指向的是B自己还是D的切片访问_a成员都通过虚基表指针来定位。这种一致性保证了指针切片后的正确行为。2.5 动手验证——写出菱形虚拟继承模型完整验证代码classA{public:int_a;};classB:virtualpublicA{public:int_b;};classC:virtualpublicA{public:int_c;};classD:publicB,publicC{public:int_d;};intmain(){D d;d.B::_a1;// 通过 B 的虚基表访问 _ad.C::_a2;// 通过 C 的虚基表访问 _a —— 但这是同一个 _ad._a3;// 最终 _a 的值是 3前面的 1 和 2 都被覆盖了d._b4;d._c5;d._d6;// 验证B 对象也使用虚基表访问 AB b;b._a7;b._b8;B*p1d;// 指向 D 对象切片视角B*p2b;// 指向 B 对象p1-_a;// p1 和 p2 的 _a 访问方式是相同的p2-_a;return0;}小结菱形虚拟继承通过虚基表存储偏移量实现了公共基类的唯一存储。B和C的虚基表中记录了各自部分到公共A的偏移距离无论指针如何切片都能正确定位到虚基类成员。三、虚函数表深入探索——揭开多态的神秘面纱3.1 重温多态与虚函数表的基础回顾多态的基础知识带有虚函数的类每个对象的前 4 个字节32 位或 8 个字节64 位存储一个虚函数表指针vfptr指向该类的虚函数表vftable。虚函数表本质是一个函数指针数组存储了该类所有虚函数的地址。当通过基类指针/引用调用虚函数时运行时会从虚表中查找实际应该调用的函数——这就是多态的底层实现。C 祖师爷Bjarne Stroustrup设计虚函数机制的目标在不牺牲效率的前提下支持运行时多态。虚函数表的引入使得多态调用的额外开销仅为一次间接寻址。然而VS 的监视窗口并不会展示虚表的全貌。为了看到真相我们需要直接读内存。3.2 单继承虚函数表——监视窗口骗了你先看单继承的场景classBase{public:virtualvoidfunc1(){coutBase::func1endl;}virtualvoidfunc2(){coutBase::func2endl;}private:inta;};classDerive:publicBase{public:virtualvoidfunc1(){coutDerive::func1endl;}// 重写 func1virtualvoidfunc3(){coutDerive::func3endl;}// 新增虚函数virtualvoidfunc4(){coutDerive::func4endl;}// 新增虚函数voidfunc5(){coutDerive::func5endl;}// 普通函数private:intb;};在监视窗口中你可能只能看到func1和func2而func3和func4却不翼而飞了真相是func3和func4就在虚表中只是被监视窗口隐藏了。下面我们写一段代码直接读取对象内存强行打印虚函数表typedefvoid(*VFPTR)();// 定义虚函数指针类型voidPrintVTable(VFPTR vTable[]){cout 虚表地址vTableendl;// VS 下虚表末尾以 nullptr 结尾G 下则不一定for(inti0;vTable[i]!nullptr;i){printf( 第%d个虚函数地址:0X%p,-,i,vTable[i]);VFPTR fvTable[i];f();// 直接调用看看打印的是什么}coutendl;}intmain(){Base b;Derive d;// 32位程序的访问思路// 1. 取对象地址强转为 int*解引用取到前 4 字节的值 - 这就是虚表指针// 2. 将虚表指针强转为 VFPTR* - 虚函数指针数组// 3. 传给 PrintVTable 打印VFPTR*vTable1(VFPTR*)(*(int*)b);PrintVTable(vTable1);// 输出// 虚表地址0x...// 第0个虚函数地址:0x...,-Base::func1// 第1个虚函数地址:0x...,-Base::func2VFPTR*vTable2(VFPTR*)(*(int*)d);PrintVTable(vTable2);// 输出// 虚表地址0x...// 第0个虚函数地址:0x...,-Derive::func1 - 重写的// 第1个虚函数地址:0x...,-Base::func2 - 未重写保持基类的// 第2个虚函数地址:0x...,-Derive::func3 - Derive 新增的// 第3个虚函数地址:0x...,-Derive::func4 - Derive 新增的return0;}关键发现重写的虚函数会替换掉虚表中原来基类虚函数的地址func1从Base::变成了Derive::未重写的虚函数保持基类的地址不变func2仍然是Base::func2派生类新增的虚函数会被追加到虚表末尾func3和func4都在表中func5是普通成员函数不在虚表中。踩坑提醒这段强制打印虚表的代码有时会崩溃。原因是编译器在许多场景下不会在虚表末尾放置nullptrVS 调试模式下会放但有时不干净导致循环越界。解决方法是点击菜单栏生成 - 清理解决方案再重新编译运行即可。3.3 多继承虚函数表——更复杂的真相多继承时的虚表结构更为复杂。看下面的例子classBase1{public:virtualvoidfunc1(){coutBase1::func1endl;}virtualvoidfunc2(){coutBase1::func2endl;}private:intb1;};classBase2{public:virtualvoidfunc1(){coutBase2::func1endl;}virtualvoidfunc2(){coutBase2::func2endl;}private:intb2;};classDerive:publicBase1,publicBase2{public:virtualvoidfunc1(){coutDerive::func1endl;}// 同时重写两个基类的 func1virtualvoidfunc3(){coutDerive::func3endl;}// 新增虚函数private:intd1;};多继承的内存布局Derive 对象内存模型 Base1 部分先继承放在前面: ---------------------- | vfptr1 - 虚表1 | - Base1 的虚函数表指针 | b1 | ---------------------- Base2 部分后继承放在后面: | vfptr2 - 虚表2 | - Base2 的虚函数表指针 | b2 | ---------------------- | d1 | - Derive 自己的成员 ----------------------关键规律多继承时Derive 对象包含多张虚表——继承了几个带虚函数的基类就有几张虚表派生类新增的虚函数放在先继承的基类的虚表中——例如func3放在 Base1 的虚表末尾重写的虚函数func1同时替换了两张虚表中对应的位置。验证代码intmain(){Derive d;// 打印第一张虚表Base1 部分VFPTR*vTableb1(VFPTR*)(*(int*)d);PrintVTable(vTableb1);// 第0个: Derive::func1 - 重写了 Base1::func1// 第1个: Base1::func2 - 未重写// 第2个: Derive::func3 - Derive 新增放在先继承的 Base1 虚表中// 打印第二张虚表Base2 部分// Base2 在内存中紧挨着 Base1需要偏移 sizeof(Base1)VFPTR*vTableb2(VFPTR*)(*(int*)((char*)dsizeof(Base1)));PrintVTable(vTableb2);// 第0个: ??? - 注意这个地址和 vTableb1[0] 不一样// 第1个: Base2::func2 - 未重写return0;}3.4 多继承中的 this 指针修正——最精妙的细节细心的同学会发现Derive 重写的func1在两张虚表中的地址居然不一样这是为什么Base1*p1d;p1-func1();// 多态调用Base2*p2d;p2-func1();// 多态调用d.func1();// 普通调用不经过虚表真相揭秘当Base2* p2 d;时由于Base2部分在Derive对象中间位置指针会发生偏移——p2指向的是Base2子对象的起始位置而不是Derive对象的起始位置。当多态调用p2-func1()时func1是被Derive重写的函数体内的this应该指向Derive 对象的开头但p2指向的是Base2 子对象的开头发生了偏移所以在调用前this 指针需要被修正回去。解决方案Base2 虚表中的func1地址并不是真正的Derive::func1而是一个包装过的 thunk 函数或者是一条调整指令它的工作是1. 先将 this 指针修正回 Derive 对象的起始地址 2. 再跳转到真正的 Derive::func1这个过程涉及相当的汇编知识。如果你有兴趣深究可以在 VS 中打断点按CtrlF11查看反汇编代码会看到sub ecx, N这样的 this 调整指令。这就是为什么两张虚表中func1的地址不同——Base1 虚表中的直接指向Derive::func1因为 Base1 就在 Derive 开头不需要修正而 Base2 虚表中的指向了一个 thunk因为需要先修正 this。3.5 菱形继承与菱形虚拟继承的虚表温馨提示菱形继承 虚函数的组合极为复杂实际开发中强烈不推荐设计出这样的继承体系。一方面太复杂容易出 bug另一方面访问基类成员有一定的性能损耗需要间接查表。这里给出一个菱形虚拟继承 虚函数的示例代码你可以通过内存窗口自行观察classA{public:virtualvoidfunc1(){}public:int_a;};classB:virtualpublicA{public:virtualvoidfunc1(){}virtualvoidfunc2(){}public:int_b;};classC:virtualpublicA{public:virtualvoidfunc1(){}virtualvoidfunc3(){}public:int_c;};classD:publicB,publicC{public:virtualvoidfunc1(){}// 重写virtualvoidfunc4(){}// 新增public:int_d;};如果好奇心强想深入研究的同学推荐阅读C 虚函数表解析C 对象的内存布局四、和多态高频面试题精讲以下是 C 面试中关于继承和多态的高频考题我将每个问题的核心要点提炼出来Q1: 什么是多态答多态就是同一个接口不同的对象调用产生不同的行为。在 C 中多态通过虚函数实现。当基类指针或引用指向派生类对象并调用虚函数时实际调用的是派生类中重写的版本——这就是运行时多态。Q2: 什么是重载、重写覆盖、重定义隐藏概念作用域关键字要求重载Overload同一作用域无函数名相同参数不同重写Override基类/派生类virtual函数名、参数、返回值完全相同必须是虚函数重定义/隐藏Hide基类/派生类无派生类的函数名和基类相同就构成隐藏Q3: 多态的实现原理答虚函数表vftable 虚函数表指针vfptr。每个含有虚函数的类有一张虚函数表存储虚函数地址每个对象的前几个字节存储指向该虚函数表的指针调用虚函数时运行时从虚表中找到实际函数地址再调用。Q4: inline 函数可以是虚函数吗答可以。但如果一个 inline 函数被声明为虚函数编译器会忽略其 inline 属性这个函数就不再是内联的。因为虚函数需要放入虚函数表中需要取地址而 inline 函数是直接在调用处展开的二者不能同时存在。Q5: 静态成员函数可以是虚函数吗答不能。原因有二静态成员函数没有 this 指针无法通过对象去访问虚函数表静态成员函数可以通过类名::函数名直接调用这种调用方式完全绕过了虚表机制。Q6: 构造函数可以是虚函数吗答不能。因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。如果构造函数是虚函数那么在构造之前就需要通过虚表查找它——但此时虚表指针还未初始化形成了鸡生蛋、蛋生鸡的悖论。Q7: 析构函数可以是虚函数吗答可以而且强烈建议将基类的析构函数定义为虚函数。经典场景Base*pnewDerive();deletep;// 如果 ~Base() 不是虚函数则只会调用 ~Base()造成资源泄漏如果基类析构函数是虚函数delete p时会从虚表中查找实际析构函数正确调用~Derive()-~Base()的完整析构链。判断准则如果一个类被设计为基类会被继承那么它的析构函数就应该声明为虚函数。Q8: 对象访问普通函数快还是虚函数更快答分两种情况普通对象调用非指针/引用一样快。因为编译器在编译期就能确定调用哪个函数不涉及虚表查找。指针/引用调用普通函数更快。因为构成多态时运行时需要通过虚函数表间接查找函数地址多了一次间接寻址的开销。Q9: 虚函数表是什么阶段生成的存在哪里答生成时机编译阶段就生成了存储位置一般情况下存在代码段常量区/只读数据段。Q10: C 菱形继承的问题虚继承的原理答菱形继承问题数据冗余同一份基类数据存多份 二义性编译器无法确定访问哪个基类的成员虚继承原理通过虚基表virtual base table存储偏移量使得公共虚基类在最终派生类对象中只存在一份。访问虚基类成员时通过虚基表指针查找偏移量来定位。注意虚函数表vftable和虚基表vbtable是两个完全不同的概念面试时不要混淆Q11: 什么是抽象类抽象类的作用答抽象类包含纯虚函数virtual void func() 0;的类。抽象类不能实例化。作用强制派生类重写虚函数——派生类必须实现纯虚函数才能被实例化体现接口继承关系——抽象类定义接口规范派生类提供具体实现。这是面向对象设计中只继承接口不继承实现的最佳实践。六、总结与思考本文核心知识图谱C 继承与多态底层核心 | -- 菱形虚拟继承 | -- 问题数据冗余 二义性 | -- 解决方案virtual 继承 | -- 核心机制虚基表存储偏移量 | -- 设计亮点统一的虚基表访问方式 | -- 虚函数表单继承 多继承 | -- 单继承一张虚表新增虚函数追加到末尾 | -- 多继承多张虚表新虚函数放在先继承的基类虚表中 | -- this 指针修正多继承中通过 thunk 修正 this | -- 反向迭代器适配器 | -- 模式适配器设计模式 | -- 核心operator* 访问前一个位置 | -- 对称设计rbegin()-end(), rend()-begin() | -- 高频面试题11题全覆盖 -- 虚函数 vs 内联 vs 静态 -- 构造/析构与虚函数 -- 虚表生成时机与存储位置 -- 抽象类的设计意义几点深度思考零开销抽象的代价C 的设计哲学是不为不用的特性付出代价。虚继承和多态的额外开销指针间接访问、内存占用只在真正使用这些特性时才会产生。这是 C 相比 Java/C# 等语言的重要优势。不要混淆虚函数表和虚基表两者虽然都以 “virtual” 为前缀都涉及指针查表但服务于完全不同的目的——前者服务于多态调用运行时确定函数地址后者服务于虚基类成员访问确定偏移量。面试时这是必考点简单的设计才是好的设计菱形继承虽然 C 支持但实际项目中应尽量避免设计菱形继承体系。同样反向迭代器的适配器模式告诉我们用组合 委托比用复杂继承更容易写出清晰、可维护的代码。面试背后的考察点那些看似刁钻的面试题“构造函数可以是虚函数吗”其实考察的是你对对象构造过程、虚表初始化时机等底层机制的深入理解。死记硬背答案是不够的——理解为什么才是关键。如果对你有帮助欢迎点赞、收藏、关注三连支持