【C++】复杂的菱形继承 及 菱形虚拟继承的底层原理

C/C++
156
0
0
2024-05-03

1. 单继承

在上一篇文章中,我们给大家演示的其实都是单继承。

单继承的概念:

单继承:一个子类只有一个直接父类的继承关系为单继承

在这里插入图片描述

2. 多继承

然后呢C++里面还支持多继承,那什么是多继承呢?

一个子类有两个或以上直接父类时称这个继承关系为多继承

在这里插入图片描述

比如一个类表示汽车,另一个类表示飞机。现在你希望创建一个新的类,使得它既可以像汽车一样在地上跑,又可以像飞机一样在天上飞,即这个新的类继承这两个基类的属性和行为,同时拥有汽车和飞机的特性。那这就是一个多继承。

2.1 多继承中指针偏移问题

然后多继承这里有一个题我们顺便做一下:

下面说法正确的是( )

在这里插入图片描述

大家先自己思考一下

其实很简单,就是我们之前讲过的切片嘛:

这里把子类对象d的地址分别赋给这三个指针

在这里插入图片描述

谁先被继承,对象模型里谁就在前面,这个后面我们会带大家观察对象模型。 所以应该是p1 == p3 != p2,选C

3. 菱形继承

多继承也不难理解,但是有时候可能会引发一些难搞的情况。

比如,多继承就有可能导致菱形继承的出现:

菱形继承是多继承的一种特殊情况。

那顾名思义,菱形继承就是继承关系近似呈一个菱形形状,比如像这样:


在这里插入图片描述

简单解释一下,首先这里有一个Person类,然后Student继承了Person,Teacher也继承了Person。 然后,又有一个Assistant(助教)类即继承了Student,又继承了Teacher。 那此时它们的继承关系就呈一个菱形状。

那菱形继承会导致什么问题呢?

3.1 菱形继承的问题——数据冗余和二义性

菱形继承就会存在一个数据冗余和二义性的问题

从下面的对象成员模型构造可以看出,在Assistant的对象中Person成员会有两份。

在这里插入图片描述

我们通过程序来带大家看一下:

代码语言:javascript

复制

class Person
{
public:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};

在这样一个菱形继承体系里面,就会存在如下一些问题:

首先呢,由于Student和Teacher都是继承Person,所以它们都拥有Person的_name属性,然后呢,Assistant又同时继承了Student和Teacher,所以就会导致在Assistant里面有两份_name,那这就导致了一个数据冗余的问题。

在这里插入图片描述

由于Assistant里面有两个_name,一个继承自Student,一个继承自Teacher,所以在访问的时候就会发生歧义,我们把它叫做数据二义性

在这里插入图片描述

我们现在想给Assistant的类对象a的_name成员赋值,那这里就无法确定你访问的是哪一个。

那有办法解决这个问题吗?

当然也有办法,我们可以通过显式指定访问哪个父类的成员来一定程度的解决二义性的问题

在这里插入图片描述

但是数据冗余的问题依然存在。 而且我们这里方便演示只给Person搞了一个成员_name,如果再多一些属性,比如住址、电话、年龄,性别等,那这样是不是都出现两份了。

那为了更好的解决菱形继承导致的数据冗余和二义性的问题,C++就引入了虚拟继承…

3.2 解决方法——虚拟继承

C++引入了虚拟继承可以解决菱形继承的二义性和数据冗余的问题

那虚拟继承是怎样的呢?

虚拟继承要用到一个新的关键字——virtual(虚拟的) 那怎么做呢?

在这里插入图片描述

在这里插入图片描述

给继承关系中第二层的类增加一个关键字virtual就行了。

然后就可以了吗?我们来看一下:


在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

这里调式窗口看起来还是多个,但其实它们是同一个(这里只是调试窗口展示出来的效果)

在这里插入图片描述

我们后面也会给大家讲一下底层的原理。

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。 如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。

3.3 虚拟继承的原理

为了研究虚拟继承的原理,我们下面给出一个简化的菱形继承的继承体系,再借助内存窗口(因为监视窗口已经看不出来底层真实的样子了)观察对象成员的模型

现在我们给出这样一个继承体系:

代码语言:javascript

复制

class A
{
public:
	int _a;
};
// class B : public A
class B : virtual public A
{
public:
	int _b;
};
// class C : public A
class C : virtual public A
{
public:
	int _c;
};
class D : public B, public C
{
public:
	int _d;
};

在这里插入图片描述

那下面我们就一起来通过内存窗口分析一下虚拟继承的原理。

首先我们先来观察一下不使用虚继承时菱形继承底层是什么样子的:


在这里插入图片描述

在这里插入图片描述

现在我创建一个D类的对象d,把它所有的成员(自己的包括继承下来的),这里我们给的都是整型(方便观察),将他们置成1到5的数值。

在这里插入图片描述

下图是菱形继承的内存对象成员模型:这里可以看到数据冗余

在这里插入图片描述

另外我们可以看到D先继承B,再继承C,在对象模型里面也是B在上面,C在下面的(就是我们上面提到的,谁先被继承,对象模型里谁就在前面)。

那我们接下来看看虚继承是怎么解决这个问题的:


在这里插入图片描述

同样的程序,我们再来观察内存空间:

在这里插入图片描述

我们发现此时整个对象的模型已经发生改变了。 我们看到,原本BC里面都存有一份_a,但是现在_a只有一个,而且单独放在最后面,那此时d对象中就只有一个_a成员了,就不存在数据冗余了,访问的时候也没有二义性了。 但是,此时BC里面原本应该存_a的位置存的是个什么东西呢? 是随机值吗? 看着也不像啊。 🆗,那告诉大家这里面存的其实是两个地址或者说指针,那它们指向的空间又存的是什么,我们可以再开两个内存窗口观察一下(注意vs上是小端存储)

在这里插入图片描述

我们观察这两个指针指向的内存空间,发现它们指向的当前位置那一个字节都是0,但是下一个字节都是一个确定的数值,一个是20(窗口显示的16进制,我们转成10进制),一个是12

那存的这些数字又分别代表什么呢?

其实仔细观察可以发现原本BC中应该存_a的位置和现在_a所在的位置,它们之间的偏移量(相对距离)就是20和12!!! 因为我们现在设置的一行刚好4个字节

在这里插入图片描述

所以,它底层原理是这样的:

原来B里面有一个_a,C里面有一个_a,这就造成了数据冗余和访问时的二义性。 所以要解决这个问题,就要只存一个_a,那就不能存到原来的位置了。 怎么办? 它放到了一个公共的位置(这个位置的_a同时属于BC两个类),那怎么找到这个_a存放的位置呢? 原来存放_a的位置就存了两个指针(叫做虚基表指针),它们分别指向一块区域(我们把它叫做虚基表),这里面就存储了原来_a在BC中的存储位置到现在_a位置的一个偏移量,通过这个偏移量就可以找到现在_a所在的位置。

那大家可能有这样的疑问,在这里也提一下:

那大家可能会想,为什么不直接存_a的地址呢?为什么还要存一个指针,通过指针去找偏移量,再通过偏移量找_a。 🆗,我们上面也说了,指针其实指向一张表,它其实指向的并不是一个位置,而是一块区域,这里面可能存了多个有用的值,一般这种我们把它叫做表(在这里名字为虚基表),另外我们其实也发现这个偏移量并没有存在指向的第一个位置

在这里插入图片描述

第一个位置是0,偏移量在后面放。 那第一个位置其实是空出来有其他用处,跟后面的多态有关系。

另外呢:

其实这里D的上一层比如说B就也是这种结构

在这里插入图片描述

因为他这里为了保持一个统一处理,正常情况下B只继承A,是不会出现数据冗余的,但这里做了统一处理。

那什么情况下会去使用偏移量找这个公共的_a呢?


在这里插入图片描述

大家看这种场景 这个是我们上一篇文章讲过的赋值转换嘛,正常情况下我们可以认为它进行一个切片嘛,把d里面属于B类的那一部分直接切出来赋给b就可以了。 但是现在虚拟继承这种情况,b里面还有从A类继承下来的_a成员是不是不在B里面啊,而是单独放到了外面,那此时要找这个_a是不是的通过虚基表指针指向的虚基表里面的偏移量找啊。

当然对于我们当前举的这个例子来说


在这里插入图片描述

我们的A这个类搞的比较小,这样一看虽然解决了数据冗余的问题,但反而多费了4个字节的空间。 但是如果A这个类比较大的话,这样处理的优势就出来了,解决了数据冗余的问题,而且节省了很多空间。

那这里对于上面的那个Person的菱形虚拟继承体系我们也给了一个原理解释图,大家可以看一下:


在这里插入图片描述

在这里插入图片描述

3.4 相关笔试题练习

下面我们来做一道这里相关的一个笔试题:

下面程序输出结果是什么?

代码语言:javascript

复制

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; 
	}
};
class C :virtual public A
{
public:
	C(const char* s1, const char* s2)
		:A(s1) 
	{ 
		cout << s2 << endl; 
	}
};
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;
	}
};
int main() 
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

选项:

在这里插入图片描述

大家先自己认真看一下这段代码,思考一下程序输出结果是什么?

🆗,我们来分析一下:

那我们观察这段代码会发现其实它还是一个菱形虚拟继承嘛,每个类里面都有一个构造函数。

然后现在我们在main函数里面new一个D对象,然后题目问我们程序输出什么?

首先告诉大家这道题的答案是A。 为什么是A呢? 这里在main函数里面new了一个D嘛,所以这里会调用D的构造函数

在这里插入图片描述

那我们看它的初始化列表,这里的顺序是B、C、A。 但是,我们说了初始化列表初始化的顺序与初始化列表里面写的顺序是无关的,而于它们声明的先后次序是一致的。 那大家记住,在这里谁先被继承,谁就先被声明 所以这里肯定是先构造A,因为D继承BC,但BC先继承了A。 然后D又先继承了B,后继承了C。 所以构造的顺序应该是ABCD,答案选A. 那大家可能会想BC初始化不是也会调用A的构造初始化自己里面A的部分吗,那这里会打印三个ClassA吗? 是不会的,因为这里菱形虚拟继承,整个D里面只有一份A,BC公用一份A,所以这里A用自己的构造函数构造一次就可以了。 因此应该是class A class B class C class D 我们验证一下:

在这里插入图片描述

没问题。

我们如果把初始化列表的顺序换一下,那也还是A,因为跟初始化列表的顺序无关,而是跟声明的顺序有关:

在这里插入图片描述

除非我们把继承顺序换了:

在这里插入图片描述

因为我们说这里继承的顺序就是声明的顺序

4. 继承和组合

这是继承


在这里插入图片描述

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。基类的内部细节对子类可见。

除了继承还有一种关系叫做组合。

组合呢是这样的:


在这里插入图片描述

其实就是一个类用另一个类的类对象作为其成员。 组合其实也是一种复用 组合是一种has-a的关系。假设D组合了C,每个D对象中都有一个C对象。C对象的内部细节对D是不可见的。

继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高

对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。

5. 继承的反思和总结

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
  3. 优先使用对象组合,而不是类继承 。 实际中尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。 类之间的关系如果可以用继承,也可以用组合,那就用组合。

6. 用到的代码

代码语言:javascript

复制

//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";
//
//	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
//	a.Student::_name = "xxx";
//	a.Teacher::_name = "yyy";
//	/*cout << &a.Student::_name << endl;
//	cout << &a.Teacher::_name << endl;
//	cout << &a._name << endl;*/
//
//	return 0;
//}

//class A
//{
//public:
//	int _a;
//};
class B : public A
//class B : virtual public A
//{
//public:
//	int _b;
//};
class C : public A
//class C : virtual public A
//{
//public:
//	int _c;
//};
//class D : public B, public C
//{
//public:
//	int _d;
//};
//int main()
//{
//	D d;
//	B b = d;
//	C c = d;
//
//	d.B::_a = 1;
//	d.C::_a = 2;
//	d._b = 3;
//	d._c = 4;
//	d._d = 5;
//	return 0;
//}


class A
{};

class B : public A
{};

class C
{};

class D
{
private:
	C _c;
};