【C++】菱形继承

前言

学习继承之后,我们认识到了继承的好处,同样还有继承存在的问题 – 多继承可能存在的菱形继承
本篇博客会已菱形继承展开一系列问题和现象的讨论。
那么话不多说,马上开始今天的学习

一. 菱形虚拟继承

我们首先还是模拟一个菱形继承,Person类,Student类,Teacher类,Assistant类
但是我们在Student类Teacher类继承Person类时,使用虚继承——关键字virtual
在这里插入图片描述

//菱形虚拟继承
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 _majorCoures;
};

void Test()
{
	Assistant a;
	a._name = "peter";


}

int main()
{
	Test();

	return 0;
}

按照我们上篇学习的,如果是普通继承,这时会造成数据冗余二义性,Assistant内部会有两个_name,直接访问也会报错,因为编译器并不知道要访问哪个_name。
但是使用虚继承后,可以直接访问。
接下来,我们通过监视窗口查看一下Assistant的变量情况

在这里插入图片描述
我们发现a中的_name都被赋值成peter了!这是怎么做到的呢?
但是监视窗口所查看的是编译器加工过得,不一定是实际的存储情况,接下来我们再深入其底层了解。

二. 虚继承的底层

接下来,我们换一个简单一些的菱形继承,方便我们了解底层

在这里插入图片描述

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;
};

void Test2()
{
	D d;
	d.B::_a = 1;
	d.B::_a = 2;

	d._b = 3;
	d._c = 4;
	d._d = 5;
}

首先,我们通过内存窗口,查看一下菱形继承D的数据存储
在这里插入图片描述
D中同时拥有两份_a,数据冗余

接下来,我们再来看菱形虚拟继承的D的内存图
在这里插入图片描述
我们发现,原本B::_a和C::_a的位置并没有再存储相应的_a的数据,而是存储了其他的东西
_a也被单独分离出来,只存在了一份。
仅从现象来看,虚继承确实解决了菱形继承的数据冗余和二义性,但是乍一看,好像D的大小还变大了?
接下来,我们先来了解原本存储B::_a和C::_a的位置,存储的新的东西是什么

1. 偏移量

上图的b0 cb 3d 00和24 cc 3d 00其实是两个地址
我们再通过内存窗口去查看这两个地址的内容
PS:注意,一般内存图看的是小端存储从左到右是低地址到高地址,数据的存储是低位放在低地址,高位放在高地址,所以我们在查找地址时,要输入的其实是003dcdb0003dcc24
在这里插入图片描述
这两个值都是十六进制的,所以是2012
这两个值,其实是偏移量/偏移地址
通过计算,我们发现B的起始地址加上20C的起始地址加上12,刚好就会到A
PS:第一个位置是预留给多态的,所以存在第二个位置

这个是D的内存图
当我们通过访问D的_a变量时候,编译器会根据B或者C的_a位置的地址去访问偏移量,进而找到_a在D中存储的位置
接下来我们查看一下虚继承下B的内存图

B b;
b._a=1;
b._b=2;

在这里插入图片描述

B中同样遵循一样的存储方式,B::_a的位置存储地址,然后第二个存储偏移量,起始地址+偏移量可以找到_a
因为这样,编译器看待这些虚继承的类的数据存储的视角是一样的,所形成的汇编指令也一样。
先找地址,再找偏移量,进而找到虚继承的成员变量

	B*ptrbb = &d;
	ptrbb->_a++;

	B b;
	B*ptrdb = &b;
	ptrdb->_a++;

同样都是访问_a,汇编指令大致相同
在这里插入图片描述

2. 小细节

(1). 成本

我们发现,如果是普通菱形继承,比如最开始A,B,C,D。D中是有5个变量的,B::_a,B::_b,C::_a,C::_c,_d。
虚继承后的是B::_b,C::_c,_a,_d还有两个指针,总共6个。这是不是使得D变大了呢?
这样看确实是这样的,但是这仅仅是A只有一个变量的情况,如果A有多个变量,那么因为数据冗余,会多一倍的数据,但是虚继承的话,就只会多两个指针,这样数据相对来说不就节省了吗。

(2). 偏移量表

虚继承后,类里面会有如同B::_a位置的指针,指向存储偏移量的表。这个表示每个对象都有不同的表吗?
答案是不是
在这里插入图片描述
我们发现,D实例化的不同对象,其使用的偏移量表都一样!

(3). 多个成员变量

如果A里面有多个变量,那指针和偏移量会有多个吗?
答案是都不会
在这里插入图片描述

偏移量表依然只有一个,偏移量也依然只有一个
即使A的变量还有结构体,内存对齐等规则,但编译器也知道内存对齐的规则,所以编译器同样也可以通过偏移量,自己去找

3. 总结

即使虚继承解决了数据冗余和二义性,但在效率上会有所减慢,因为编译器还需要通过偏移量去找数据。

三. 小试牛刀

class A
{
public:
	A(const char*s)
	{
		cout << s << endl;
	}

	~A()
	{}
};

class B :virtual public A
{
public:
	B(const char*s1, const char*s2)
		:A(s1)
	{
		cout << s2 << endl;
	}

	~B()
	{}
};

class C :virtual public A
{
public:
	C(const char*s1, const char*s2)
		:A(s1)
	{
		cout << s2 << endl;
	}

	~C()
	{}
};

class D :public B,public C 
{
public:
	D(const char*s1, const char*s2,const char*s3,const char*s4) 
		:B(s1,s2)
		,C(s1,s3)
		,A(s1)
	{
		cout << s4 << endl;
	}

	~D()
	{}
};

int main()
{

	D*p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;

}

请问,打印内容是什么?

此处我们需要关联前面的知识点。
初始化顺序初始化列表没有关系,而是跟变量的声明顺序有关。
而在继承这里,谁先继承谁就先声明
A是最先被B和C继承的,所以最先调用的构造函数也是A,然后在D的继承是先继承B,再继承C的,所以其次构造函数调用是先调用B再调用C,最后调用D
所以打印的内容是,class A class B,class C,class D
在这里插入图片描述

如果我们将题目改一下
在这里插入图片描述

D的继承顺序还是先继承B,再继承C,但是初始化列表先调用C的构造函数,再调用B的构造函数。结果又将变成什么呢?
在这里插入图片描述
答案依旧是class A class B,class C,class D
因为构造的顺序和初始化列表的顺序无关,跟声明的顺序有关,我们没有改变继承的顺序,声明的顺序就没有改变。

这次我们改变继承的顺序
在这里插入图片描述
结果当然就跟着变了
在这里插入图片描述

四. 公有继承和组合

另外,在实际的开发中,在复用父类,减少代码冗余,除了使用继承,还可以使用组合
以下是继承和组合的设计方法

在这里插入图片描述

继承,是B继承A的所有属性is-a
组合,是D中有一个C的对象has-a
这两种模式,亲密度,或者说耦合度,继承会比组合高。
因为B是完全继承A,一旦A的保护成员改变,那么B的也需要跟着改变。
但是C只是D中的一个变量,在D中无法显示调用C的保护和私有成员,所以C的改变对D的影响相对来说更小。

接着,我们在来谈论继承的耦合度比组合更高。

假如我们定义一个鸟类,使用继承的方法,我们可以分出很多不同的鸟类。但是对于一些部分的差异,我们究竟要不要写在父类中呢?

  1. 继承:
    比如,大部分鸟都会飞,那我们就要在鸟类提供Fly()的接口吗?但是还有很多鸟类不会飞呀,比如鸵鸟,企鹅等。或许你说,我们还可以将鸟分为会飞的鸟和不会飞的鸟,行,这确实可以解决问题,但是还有很多其他的差异化,难道我们都需要一个一个去细分吗?那这样实现的子类会不会太多太复杂了。
    同时,这样也破坏了封装的特性,因为子类的使用需要完全依赖父类,这需要我们不断的向上查找某个接口最初实现的父类
  2. 组合:
    组合的思路和继承有所差异,正如上面我们简单的样例,我们仅仅是在别的类中实例化出一个类,使用这个类提供的公共接口,而对于其没有的接口,我们可以自己额外设计,不用完全依赖于这一个类

再拿生活中的一个实际的例子举例
继承就像是跟团旅游,整个团体是一个整体,如果一个人迟到了,我们需要等待哪一个人;到达一个景点,即使你觉得不好玩,但依然得跟着大部队待在那里;遇到好玩的景点,你想多玩会,恐怕也不行,得跟上大部队。
组合就像自由旅游,导游大致讲解这个地方的游玩项目,你可以根据自己的喜好分配游玩顺序和时间,但是在指定时间需要集合,也不能出指定范围。

在设计的时候,我们都秉承低耦合,高内聚
所以,我们如果在选择继承和选择时,大多会选择组合。
实际尽量多去用组合。组合的耦合度低代码维护性好。不过继承也有用武之地,有些关系适合继承就用继承,另外要实现多态,也必须使用继承。类之间的关系可以使用继承,可以用组合,就用组合。

结束语

如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。
在这里插入图片描述

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
菱形继承指的是一个派生类继承自两个直接或间接基类,而这两个基类又共同继承自一个共同的基类,导致派生类中存在两份共同基类的数据成员,从而产生了命名冲突和二义性的问题。 解决菱形继承问题的一种方法是使用虚拟继承。虚拟继承可以使得共同基类在派生类中只有一份实例,从而避免了数据成员的重复和命名冲突问题。在使用虚拟继承时,需要在继承语句前加上关键字 virtual,例如: ``` 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; }; ``` 在上面的例子中,B 和 C 都虚拟继承自 A,而 D 继承自 B 和 C。因此,在 D 中只有一份 A 的实例,从而避免了数据成员的重复和命名冲突问题。 在初始化菱形继承的派生类时,需要注意以下几点: 1. 派生类的构造函数必须调用每个直接基类的构造函数,以及虚拟基类的构造函数,顺序为先虚拟基类,再按照继承的顺序调用直接基类的构造函数。 2. 虚拟基类的构造函数由最底层的派生类负责调用,其他派生类不需要再次调用虚拟基类的构造函数。 3. 派生类的析构函数必须调用每个直接基类的析构函数,以及虚拟基类的析构函数,顺序为先按照继承的顺序调用直接基类的析构函数,再调用虚拟基类的析构函数。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值