当前位置: 首页 > ops >正文

虚拟继承:破解菱形继承之谜

目录

一、虚拟继承的引入

二、虚拟继承的实现

virtual关键字的位置

正确用法:

为什么要加在这里:

关键规则:

三、菱形继承的内存布局分析

1、普通菱形继承的问题

2、虚拟继承的内存布局

1. 内存布局回顾

2. 偏移量的计算逻辑

对于B类的虚基表:

对于C类的虚基表:

3. 关键解释:偏移量的相对性

正确的计算方式:

4. 为什么B的偏移量比C大?

5. 实际编译器实现

6. 验证方法

四、切片操作与虚基表

五、总结与最佳实践

六、进阶话题

1、构造函数调用顺序

类继承关系:

虚继承的作用:

Assistant 的构造函数:

对象布局:

结论:

2、多继承中的指针偏移

内存布局:

指针值分析:

结论:

正确答案:C:p1 == p3 != p2

验证代码:

3、IO库中的菱形虚拟继承(了解即可)

七、结论


一、虚拟继承的引入

        上一篇博客中说到,C++的语法复杂性常被诟病,多继承机制便是典型例证。这种设计导致了菱形继承问题,进而衍生出更复杂的菱形虚拟继承结构,不仅增加了底层实现的复杂度,还带来性能损耗。因此在实际开发中应尽量避免设计出菱形继承结构。多继承被视为C++的重要设计缺陷之一,这也是后续语言如Java等选择放弃该特性的原因。

        为了解决C++中菱形继承带来的二义性和数据冗余问题,引入了虚拟继承机制。在传统的菱形继承结构中,派生类会包含多个基类副本,导致资源浪费和访问歧义。通过虚拟继承,可以确保在菱形继承 hierarchy(层级) 中,最顶层的基类只有一个实例被共享。


二、虚拟继承的实现

以下代码展示了如何使用虚拟继承解决菱形继承问题:

#include <iostream>
#include <string>
using namespace std;class Person {
public:string _name; // 姓名
};class Student : virtual public Person { // 虚拟继承
protected:int _num; // 学号
};class Teacher : virtual public Person { // 虚拟继承
protected:int _id; // 职工编号
};class Assistant : public Student, public Teacher {
protected:string _majorCourse; // 主修课程
};int main() {Assistant a;a._name = "peter"; // 无二义性//Assistant必须直接初始化Person基类。使用构造或者使用派生类可见的继承方式!!!// 验证名称一致性cout << a.Student::_name << endl; // 输出: petercout << a.Teacher::_name << endl; // 输出: peter// 验证地址一致性cout << &a.Student::_name << endl; // 输出相同地址cout << &a.Teacher::_name << endl; // 输出相同地址return 0;
}

        多继承机制虽然提供了语法支持,但在实际应用中应避免设计菱形继承结构。因为一旦采用虚拟继承,无论是使用层面还是底层实现都会变得异常复杂。Java语言选择不支持多继承,正是为了避免菱形继承带来的各种问题。

virtual关键字的位置

        在菱形继承(Diamond Inheritance)中,virtual关键字应该加在中间层类(Student和Teacher)继承Person的地方。

正确用法:

class Student : virtual public Person {  // 这里加virtual// ...
};class Teacher : virtual public Person {  // 这里加virtual// ...
};

为什么要加在这里:

  • 虚继承的位置virtual关键字应该放在中间派生类(Student和Teacher)继承基类(Person)的时候

  • 如果不加virtual

    class Student : public Person {  // 没有virtual
    class Teacher : public Person {  // 没有virtual

    会导致Assistant对象中包含两份Person子对象:

    • 一份来自Student路径

    • 一份来自Teacher路径

  • 加了virtual之后

    class Student : virtual public Person {  // 有virtual
    class Teacher : virtual public Person {  // 有virtual

    保证Assistant对象中只有一份Person子对象

关键规则:

  1. virtual加在中间层:Student和Teacher继承Person时

  2. 最派生类负责初始化:Assistant必须直接初始化Person基类(使用构造或者使用派生类可见的继承方式!!!)

  3. 避免二义性:确保_name等成员只有一份

这样设计后,Assistant对象的内存布局中Person基类只有一份,避免了菱形继承的二义性问题。


三、菱形继承的内存布局分析

1、普通菱形继承的问题

在此之前,我们先看看不使用菱形虚拟继承时,以下菱形继承当中D类对象的各个成员在内存当中的分布情况。

#include <iostream>
using namespace std;class A {
public:int _a;
};class B : public A {
public:int _b;
};class C : public A {
public:int _c;
};class D : public B, public C {
public:int _d;
};int main() {D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;
}

在此情况下,D类对象包含两个独立的_a成员,分别来自B和C继承路径,导致数据冗余和访问二义性。

        通过内存窗口,我们可以看到D类对象当中各个成员在内存当中的分布情况如下,先继承的在前,后继承的在后面,通过地址可以看出来:

也就是说,D类对象当中各个成员在内存当中的分布情况如下:

这里就可以看出为什么菱形继承导致了数据冗余和二义性,根本原因就是D类对象当中含有两个_a成员。

2、虚拟继承的内存布局

现在我们再来看看使用菱形虚拟继承时,以下菱形继承当中D类对象的各个成员在内存当中的分布情况。

#include <iostream>
using namespace std;class A {
public:int _a;
};class B : virtual public A {
public:int _b;
};class C : virtual public A {
public:int _c;
};class D : public B, public C {
public:int _d;
};int main() {D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;
}

通过内存窗口,我们可以看到D类对象当中各个成员在内存当中的分布情况如下:

        在D类对象中,成员_a被移至末尾,而原本存放两个_a成员的位置被替换为两个虚基表指针。这些指针分别指向各自的虚基表。虚基表包含两项数据:第一项是为多态虚表预留的偏移量存储位置(此处无需关注),第二项则表示当前类对象位置与公共虚基类之间的偏移量。通过这一机制,两个指针最终都能经过计算定位到成员_a。

使用虚拟继承后,D类对象中的内存布局发生变化:

  • _a成员被放置在对象末尾

  • 原来存放_a的位置被虚基表指针取代

  • 每个虚基表指针指向一个虚基表,其中包含偏移量信息

  • 通过这些指针和偏移量计算,可以定位到共享的_a成员

        前面的虚基表(B的虚基表)中的偏移量值(14 00 00 00 = 20)确实比后面的虚基表(C的虚基表)中的偏移量值(0c 00 00 00 = 12)要大。这看起来反直觉,但其实是完全合理的。以下是详细分析:

1. 内存布局回顾

让我们先明确D类对象在内存中的布局(假设从地址0x00000000开始,简明易看):

地址        内容                说明
─────────────────────────────────────────────────
0x00000000  ████               B的虚基表指针(vbptr_B)
0x00000008  █ 03 00 00 00 █    B::_b = 3
0x0000000C  ████               C的虚基表指针(vbptr_C)  
0x00000014  █ 04 00 00 00 █    C::_c = 4
0x00000018  █ 05 00 00 00 █    D::_d = 5
0x0000001C  █ 02 00 00 00 █    A::_a = 2 (共享)

2. 偏移量的计算逻辑

对于B类的虚基表:
  • B对象起始地址0x00000000

  • A对象位置0x0000001C

  • 偏移量 = A的地址 - B的地址 = 0x1C - 0x00 = 28字节 = 0x1C

但实际存储的是14 00 00 00(20字节),为什么?

对于C类的虚基表:
  • C对象起始地址0x0000000C

  • A对象位置0x0000001C

  • 偏移量 = A的地址 - C的地址 = 0x1C - 0x0C = 16字节 = 0x10

但实际存储的是0c 00 00 00(12字节),为什么?

3. 关键解释:偏移量的相对性

虚基表中存储的偏移量不是从当前子对象开始到A的绝对偏移,而是从虚基表指针位置开始计算的偏移量!

正确的计算方式:
  • B的虚基表指针0x00000000

    • 到A的偏移量:0x1C - 0x00 = 28字节 = 0x1C

    • 但编译器可能考虑了其他因素(如对齐、实现细节)

  • C的虚基表指针0x0000000C

    • 到A的偏移量:0x1C - 0x0C = 16字节 = 0x10

4. 为什么B的偏移量比C大?

因为B子对象在内存中的位置比C子对象更靠前

  • B从0x00000000开始

  • C从0x0000000C开始

  • A在0x0000001C

所以:

  • B到A的距离:0x1C - 0x00 = 28字节

  • C到A的距离:0x1C - 0x0C = 16字节

B需要跨越更长的距离才能到达A,因此它的偏移量值更大。

5. 实际编译器实现

不同的编译器可能有细微的实现差异:

  • VS编译器可能在虚基表指针之前或之后有额外的信息

  • 偏移量的计算可能考虑了编译器内部的数据结构

  • 内存对齐要求可能导致实际偏移量与理论值略有不同

6. 验证方法

可以在调试时验证,可以回想切片原理机制,这样就想明白了:

D d;
B* pb = &d;
C* pc = &d;
A* pa = &d;cout << "B到A的偏移: " << (char*)pa - (char*)pb << endl;
cout << "C到A的偏移: " << (char*)pa - (char*)pc << endl;

        前面的虚基表(B)的偏移量比后面的(C)大,是因为B子对象在内存中位置更靠前,它需要跨越更长的距离才能到达共享的虚基类A。 这正体现了虚拟继承的精妙设计——每个子对象通过自己的虚基表指针,都能正确地找到共享的基类成员。


四、切片操作与虚基表

        当进行切片操作时:我们若是将D类对象赋值给B类对象,在这个切片过程中,就需要通过虚基表中的第二个数据找到公共虚基类A的成员,得到切片后该B类对象在内存中仍然保持这种分布情况。

D d;
B b = d; // 切片行为

        切片后的B类对象仍然保持特殊的内存布局(也就是虚基表),其中实例化对象b的_a成员位于b对象的末尾,通过虚基表指针和偏移量进行访问。得到切片后该B类对象当中各个成员在内存当中的分布情况如下:(红色为D类实例化对象d的内存布局,蓝色为B类实例化对象b的内存布局)


五、总结与最佳实践

  1. 复杂性考量:C++的多继承机制确实增加了语言复杂性,菱形虚拟继承的实现机制尤为复杂

  2. 设计建议:尽量避免设计菱形继承结构,以减少代码复杂性和潜在性能开销

  3. 语言比较:许多现代面向对象语言(如Java)选择不支持多继承,避免了菱形继承问题

  4. 使用场景:如果必须使用多继承,确保理解虚拟继承的机制和影响


六、进阶话题

1、构造函数调用顺序

在虚拟继承中,构造函数调用顺序需要特别注意:

#include <iostream>
#include <string>
using namespace std;class Person {
public:Person(const char* name) : _name(name) {}string _name;
};class Student : virtual public Person {
public:Student(const char* name, int num) : Person(name), _num(num) {}
protected:int _num;
};class Teacher : virtual public Person {
public:Teacher(const char* name, int id) : Person(name), _id(id) {}
protected:int _id;
};class Assistant : public Student, public Teacher {
public:Assistant(const char* name1, const char* name2, const char* name3): Person(name3),  // 虚基类由最派生类直接初始化Student(name1, 1),Teacher(name2, 2) {}
protected:string _majorCourse;
};int main()
{// 思考⼀下这⾥a对象中_name是"张三", "李四", "王五"中的哪⼀个?Assistant a("张三", "李四", "王五");return 0;
}

类继承关系

  • Person 是一个基类,有一个成员 _name

  • Student 和 Teacher 都虚继承自 Person(使用 virtual public 继承)。

  • Assistant 同时继承自 Student 和 Teacher(多继承)。

虚继承的作用

  • 虚继承是为了解决多继承时的菱形继承问题(避免同一个基类在派生类中有多份拷贝)。

  • 由于 Student 和 Teacher 都虚继承 Person,所以在 Assistant 对象中,Person 子对象只有一份(由最派生类 Assistant 直接初始化)。

Assistant 的构造函数:

Assistant(const char* name1, const char* name2, const char* name3): Person(name3),  // 直接初始化虚基类PersonStudent(name1, 1),Teacher(name2, 2) {}
  • 这里,Person 的初始化由 Assistant 直接完成(通过 Person(name3)),而不是通过 Student 或 Teacher 的初始化列表(因为虚基类由最派生类直接初始化)。

  • 因此,Person 的 _name 被初始化为 name3(即"王五")。

  • Student 和 Teacher 的初始化列表中也会尝试初始化 Person(例如 Student 的构造函数有 Person(name)),但由于虚继承机制,这些初始化会被忽略(因为最派生类已经直接初始化了虚基类)。

对象布局

  • 在 Assistant 对象中,Person 子对象只有一份,其 _name 由 Assistant 的初始化列表中的 Person(name3) 设置(即"王五")。

  • Student 和 Teacher 子对象中的 Person 部分实际上都是同一个(共享的),所以它们的 Person 初始化(Student 中的 Person(name1) 和 Teacher 中的 Person(name2))被跳过。

结论

    a 对象中的 _name 是 "王五"(由 Assistant 直接初始化虚基类时传入的 name3)。因此,a 对象中的 _name 是 "王五"

2、多继承中的指针偏移

下面说法正确的是(        ) 

A:p1==p2==p3        B:p1<p2<p3        C:p1==p3!=p2        C:p1!=p2!=p3

class Base1 { public: int _b1; };          // 大小:4字节
class Base2 { public: int _b2; };          // 大小:4字节  
class Derive : public Base1, public Base2 { public: int _d; };  // 大小:12字节int main() {Derive d;Base1* p1 = &d;    // 指向Base1子对象Base2* p2 = &d;    // 指向Base2子对象(需要偏移)Derive* p3 = &d;   // 指向整个对象起始位置// p1、p2、p3的值可能不同,因为指向对象内的不同子对象return 0;
}

内存布局:

指针值分析:

  • p3:指向整个Derive对象的起始地址(即Base1子对象的起始地址)

  • p1:也指向Base1子对象的起始地址,所以 p1 == p3

  • p2:指向Base2子对象的起始地址,需要从Derive对象起始地址偏移4字节(Base1的大小),所以 p2 == p3 + 4字节

结论:

  • p1 == p3(都指向对象起始位置)

  • p2 != p1 且 p2 != p3(p2需要偏移)

  • p2 > p1 且 p2 > p3(在大多数平台上,地址值数值上更大)

正确答案:C:p1 == p3 != p2

验证代码:

#include <iostream>
using namespace std;class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;cout << "p1: " << p1 << endl;cout << "p2: " << p2 << endl; cout << "p3: " << p3 << endl;cout << "p1 == p3: " << (p1 == p3) << endl;  // truecout << "p1 == p2: " << ((char*)p1 == (char*)p2) << endl;  // falsecout << "p2 == p3: " << (p2 == p3) << endl;  // falsereturn 0;
}

输出结果通常是:

3、IO库中的菱形虚拟继承(了解即可)

C++标准库中的IO流体系也使用了虚拟继承:

template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits> {};template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits> {};

这种设计避免了iostream同时继承istream和ostream时的菱形继承问题。


七、结论

        虚拟继承是C++中解决菱形继承问题的有效机制,但同时也增加了语言复杂性和运行时开销。在实际开发中,应当优先考虑使用组合而非继承,或者使用单继承加接口的设计模式来避免菱形继承问题。如果必须使用多继承,应充分理解虚拟继承的原理和影响,并进行充分测试。

http://www.xdnf.cn/news/19590.html

相关文章:

  • 【论文阅读】Deepseek-VL:走向现实世界的视觉语言理解
  • Postman接口测试工具:高效管理测试用例与环境变量,支持断言验证及团队协作同步
  • 软件设计师——软件工程学习笔记
  • 前端架构知识体系:常见压缩算法全解析及原理揭秘(gzip、zip)
  • 麒麟信安受邀出席第三届电子信息测试产业大会,参编四项团标发布,详解麒麟信安操作系统测试全流程
  • Navicat vs DBeaver vs DataGrip:三款主流数据库客户端深度对比与选择
  • 力扣222 代码随想录Day15 第四题
  • 【高并发内存池】三、线程缓存的设计
  • Steam开发者上架游戏完整指南(含具体技术细节)
  • 【最新Pr 2025安装包(Adobe Premiere Pro 2025 中文解锁版)安装包永久免费版下载安装教程】
  • Java-Spring入门指南(一)Spring简介
  • 如何把HTML转化成桌面Electron
  • B树和B+树,聚簇索引和非聚簇索引
  • 网络准入控制,阻断违规外联-企业内网安全的第一道防线
  • 通用的二叉数迭代方法
  • 深入浅出 RabbitMQ-TTL+死信队列+延迟队列
  • 如何使用Kafka处理高吞吐量的实时数据
  • 赵玉平《跟司马懿学管理》读书笔记
  • 智能高效的Go IDE——GoLand v2025.2全新上线
  • 图像编码--监控摄像机QP设置大小?
  • Git 代码提交管理指南
  • 为啥我Nginx证书配的没问题,但客户端却发现证书不匹配?
  • 从零开始搭建体育电竞比分网,手把手教你全流程
  • 京东科技大模型RAG岗三轮面试全复盘:从八股到开放题的通关指南
  • 若想将gpu的代码在昇腾npu上运行,创建docker应该创建怎么样的docker?(待完善)
  • 从模态融合到高效检索:微算法科技 (NASDAQ:MLGO)CSS场景下的图卷积哈希方法全解析
  • 【XR硬件系列】Apple Vision Pro 完全解读:苹果为我们定义了怎样的 “空间计算” 未来?
  • 【C语言指南】回调函数:概念与实际应用的深度剖析
  • 【LeetCode热题100道笔记】前 K 个高频元素
  • 4种有效方法将联想手机数据传输到电脑