humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


C++ 2022 年 7 月 23 日

[C++] 菱形继承和虚拟菱形继承 原理分析

C++继承的使用,有许多需要特别注意的地方. 本篇文章的内容就是 分析一下 C++继承中的特别需要注意的地方:菱形继承
上一篇文章差不多就是C++继承基础用法的所有内容了
但是C++继承的使用,还有需要特别注意的地方
本篇文章的内容就是 分析一下 C++继承中的特别需要注意的地方:菱形继承

单继承 与 多继承

  1. 单继承:一个子类只拥有一个直接父类,继承关系是直上直下的

    想这样的关系:Student 继承 PersonDoctor 继承 Student。就属于 直上直下的继承关系

  2. 多继承:一个子类拥有多个直接父类

    想这样,Assistant(助教) 分别继承了 StudentTeacher,就是一种多继承

菱形继承 和 菱形虚拟继承

C++ 中 存在多继承的概念和用法,那么就一定会出现一种情况:菱形继承

什么是菱形继承

一张图就可以明确 菱形继承的概念和格式:
StudentTeacher 都继承了 Person
Assistant 多继承了 StudentTeacher
这样 就形成了一个 类似菱形的继承关系

并不是只有图示这样的才叫菱形继承,像这样

只要体系中 有相同父类的两个或多个子类被另一个子类继承了,就会形成菱形继承

形成菱形继承会 出现两个问题:
  1. 二义性,即 最下面的子类的对象中 存在多个相同的成员
  2. 数据冗余,即 最下面子类的对象中 存储了多个相同的内容
针对 存在 二义性成员 的对象,直接访问 这种成员,是会报错的:
内容是,访问不明确
不过,二义性无法直接访问的问题 其实是可以通过 指明成员的类域 进行解决的
但是 数据冗余 就不能这么简单的解决了

好像数据冗余也没有太大的影响?

然而并不是,对于小占用的数据是如此,如果是大占用的呢?

这样的双倍,就不会影响小了

其实,要更好的解决 由于菱形继承造成的 数据冗余和二义性,需要 虚拟继承

菱形虚拟继承及其原理

C++ 种提供了一个 关键字 virtual
把它加在 形成 菱形继承的体系的腰部类中,就可以实现 零星虚拟继承,进而解决 菱形继承数据冗余和二义性的问题
即,这个位置:
菱形虚拟继承 的作用是什么?
通过 VS2022 的监视窗口观察,好像并没有什么优化,甚至还多出了一个 Person
但其实并不是这样的,是 VS 的监视窗口优化出了问题,显示的其实并不准确,去看内存中的存储 更能看出一些内容:

可以使用,简单的类来 查看菱形虚拟继承的对象模型,对象在内存中的存储 就是对象模型

我们使用更简单的菱形虚拟继承体系 来分析 对象模型:

PS:博主的 VS执行环境是 64位 的,所以查看地址需要 8字节 的查看

32位环境,请 4 字节查看

A 类成员变量 _a 给缺省值 10

先查看一般的 菱形继承 的对象模型**(8字节查看)**:
&d1_对象模模型
&d1_对象模模型
可以看到,普通菱形继承的 对象d1 的对象模型是这样的
再来看一下,菱形虚拟继承的对象模型:

分别分析一下,两种不同的对象模型:

非虚拟继承的对象模型:

为了 方便观察,给 对象的成员赋了值,且 A对象的成员 _a 给了缺省参数 10:

对比来看 对象模型

可以发现,内存中,从低到高 每8个字节 分别存储的是:

再对比 对象d1 中的数据,会发现,对象模型中存储的是:

  1. B::A::_a = 10 B::_b = 4
  2. C::A::_a = 10 C::_c = 16
  3. D::_d = 20

所以 非虚拟菱形继承 的对象模型,在内存中存储的就是 B、C 和 自己的成员,且是紧挨着存储的

即:

菱形虚拟继承的对象模型:

同样是给 对象的成员赋了值,且 A对象的成员 _a 给了缺省参数 10:

观察 其对象模型,可以发现存储的是地址和数值:

对象中 存储的数据 确实都在呢:_b = 4, _c = 16, _d = 20, _a = 10 4、16、20、10 都存储在对象模型中,但是除此之外还有两个不知道是什么的地址

观察那两个地址:

按照 4 字节查看,可以看到 那两个地址下面各自存放了一个数值:40 和 24

其实,这两个数值 表示 偏移量,而 存放那两个地址的位置是指针,被称为虚基表指针,存放偏移量的这个地方,被称为 虚基表

这两个偏移量,其实是 虚基表指针 相对于 _a 存储位置的偏移量

所以其实,B 和 C类还在 对象模型中

只不过相对于 非模拟继承,直接存储 _a 变成了使用两个指针指向虚基表,从表中找到相对偏移量,再由偏移量找到 _a

这种方法非常的麻烦,但是很有效的解决了 数据冗余和二义性 问题

因为 最高层父类的数据只存储了一份,如果想访问 就使用指针和偏移量去找,访问的都是同一个位置

这就是 菱形虚拟继承 解决 数据冗余和二义性 的方案
很复杂,但有用,所以 最好最好最好不要设计出菱形继承

可能存在的问题:

虽然可能存在二义性的数据只留下了一个且被存放在了对象空间的下面,但是 数据依旧都在同一个对象中,为什么还需要存储偏移量,使用时还需要用偏移量来找到原本应该属于自己的数据呢?

这个问题的原因或许有许多的解答,但是这里我只解释一个地方

先分析另一个问题:

还是以 A,B,C,D 这个继承体系为例:

如果不存储偏移量去找数据,怎么完成这个赋值的操作

子类对象赋值给父类对象是会发生切割的,会只保留 对应类的部分 再赋值给 对应的类.

如果,不存储偏移量不去找数据,那么 切割过去的就是一个指针 而不是数据,那就说明赋值失败了.

对象 b 和 c 需要的是数据,而不是指针,也不会去找偏移量,更不会通过偏移量去找数据.

所以,既然 子类对象中的数据被挪走了,就需要记录位置 以便使用时可以找得到


根据分析 简单类继承体系的对象模型,其实可以画出 Person、Student、Teacher和 Assistant 这个继承体系的对象模型:

菱形继承的关键源头,其实不在于菱形继承,而在于多继承

继承总结

文章读到这里,C++中关于继承的知识部分已经差不多介绍完了
但是,关于关于C++中,关于继承还有一些意识上的东西:
  1. C++多继承的设计 是有许多人说 C++ 语法复杂的原因之一,因为太复杂,太繁琐。有了多继承就肯定会出现菱形继承,为了解决菱形继承出现的问题,又有了菱形虚拟继承,底层实现过于复杂。这其实也可以看作 C++ 语法设计的缺陷之一,作为吸取了不少C++语法的经验,Java 就直接把多继承给舍弃了。

  2. 关于 组合 和 继承

    • 继承实现的是,将 其他类 融入到 我的类之中,实现了 子类对象可以看作就是父类对象的现象,即 A对象就是B对象。从 子类对象可以无类型转化赋值给父类对象 就可以说明此现象

    • 组合则实现的是,在我的类中 定义一个其他类的对象作为我的成员,实现了 A对象有B对象的现象。

    • 组合 和 继承 是C++中代码复用的两种方式,不过在实际的使用中 优先考虑使用对象组合,为什么呢?

      组合 和 继承都是代码复用的手段,但是 组合 相对于 继承 是属于 高内聚,低耦合 的。

      什么是耦合?在代码中 耦合 可以看作代码之间的关联度,在程序设计中,需要遵循的一个规则就是:尽量的高内聚,低耦合,进而降低不同代码之间的关联度。

      低耦合的代码,可以做到 一方修改代码 几乎不影响 另一方,组合就是这样的。使用对象组合,类实现的底层是不会暴露出来的,这样的复用 可以称为 黑箱(黑盒)复用(black-box reuse) ,因为不给使用者提供底层细节,所以使用时就只能调用接口来执行操作,一方的代码变动,但是接口不变的话,另一方依旧可以正常使用

      而继承,相对耦合就高了。因为 父类融入了子类中,修改父类的代码对子类的影响有时是非常大的。使用继承 子类可以直接访问父类的成员,即底层,这样的复用 可以称为 白箱(白盒)复用(white -box reuse)。因为父类的底层细节是直接暴露给子类的, 所以子类成员实现某些功能的时候,很可能会直接使用父类成员进行操作 而不是使用接口。所以当父类代码修改的时候,很可能对子类影响很大,导致子类也需要修改代码

      所以 优先考虑使用对象组合,继承的维护成本比组合的维护成本要高很多!!

    • 组合一般在类似这种情况下使用。比如:车有4个轮子,人有一双手等,不能说 车是4个轮子 或 4个轮子是车

    • 继承一般在类似这种情况下使用。比如:狗是动物,猫是动物,同样的不能说 动物是猫,动物是狗

  3. 继承 是在一定程度上 破坏类的封装,所以 当组合 和继承都可以使用的时候,优先使用组合

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2022 年 7 月 23 日