前言
在我们前面所说的继承其实在C++中也叫做单继承
即一个子类只有一个直接父类的时候称这个继承关系为单继承
一、多继承
一个子类有两个或以上直接父类时称这个继承关系为多继承
多继承即认为一个对象可能同时有其他两个或以上对象的属性所设计出来的。
class Student { protected: int _num; //学号 }; class Teacher { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 }; int main() { Assistant at; return 0; }
如上代码所示,Assistant继承了Student,Teacher两个类的属性
二、菱形继承
虽然多继承看似很合理,但是多继承引发了一种新的问题——菱形继承
菱形继承是多继承的一种特殊情况
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份
菱形继承导致的问题也正是如此。如下代码所示,就会产生二义性
此处的二义性,我们还可以通过类域指定访问去处理
监视显示为
还有一种情形要注意:我们如果指定的是父类的父类的话,编译器是可以通过的
但是此时的结果究竟指向哪个Student里的父类还是Person的父类呢?其实是跟继承的顺序有关的,我们写多继承的时候先是继承了Student,所以Student里面的Perosn中的_name会被修改为王五
三、菱形虚拟继承
在前面,我们得知了由多继承导致的菱形继承中的一些问题:数据冗余和二义性
为了解决这个问题,C++又出来了一个菱形虚拟继承
菱形虚拟继承只需要在菱形继承中的腰部位置(即Student和Teacher类)添加关键词virtual即可
有了菱形虚拟继承,我们可以不需要指定类域去访问_name,我们的Person在监视窗口看好像是存了三份,并且我们修改数据的时候三份同时进行修改。
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
四、菱形虚拟继承的底层原理
我们用如下代码进行研究
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; }
上面是一个普通的菱形继承,它的运行结果应为如下所示
这里我们要注意,我们看似这里好像是给包了起来。实际中是连续的内存进行存放的。
如上是菱形继承的底层内存分布
接下来看菱形虚拟继承的底层分布
我们还是上面的例子,只不过将其改为菱形虚拟继承。
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; }
这样的话,我们从监视窗口看好像是有三份,其实不然,这里并非三份。而是一份
我们从内存窗口可以更加精确的观测到内存的变化
我们发现,a似乎只有一份,且对象模型发生非常大的变化
也就是说,现在的A既不在B也不在C。这里倒是还可以理解,因为为了解决数据冗余二义性,它需要放到其他位置上,具体方最上面还是最下面是取决于编译器自己规定的
但是B和C里面似乎又多了一些东西,这些东西又是什么呢?如下图青色部分所示
我们从模样上来看,这两个有点像指针
于是我们从内存中找到这里指向的位置,如下所示(注意是小端机器)
我们可以注意到,这个指针指向的位置存的是一个0,然后下一个位置存储的是一个有效值
其中,前者为20,后者为12我们不难注意到以下关系。他们距离A的地址正好相差这么多
所以这里指针存储的是距离A的偏移量。那么现在可能我们会好奇,为什么要搞一个指针,不直接存的。直接存不可以吗?其实是可以的。但是为什么我们编译器没有这样做呢?
我们注意到我们的偏移量是存在第二个里面,第一个里面是0,这个0是为其他值预留的。如果还有其他值的话,可以直接存进来。 因为菱形虚拟继承的,可能不止这一个。
而且这里仅仅只是一个类型,我们可能要实例化很多个对象,每个对象如果都是直接存储的话,那么代价太大了,不如直接开辟一个空间将偏移量全部放进去,然后每个对象只有一个指针指向即可。
如下就是两个对象的时候,他们都是存储相同的指针
这里的映射,我们有时候也称之为虚基表(寻找基址偏移量的表)
而且在如下的场景下,我们的这里是菱形虚拟继承的情况下,不仅仅是D的对象模型是前面的样子,B的对象模型也是类似于D的,它有一个指针指向一个虚基表,然后存储一个偏移量,根据这个偏移量就可以找到A的部分
以及类似的下面指针的情形,切割的场景,也是类似的,这个指针指向的仅仅只有B的那一部分,pb指针是切割出来的。为了能够找到对应的_a,必须通过偏移量进行寻找
,不通过偏移量编译器控制不住
还有如下所示的样例
int main() { B b; b._a = 1; b._b = 2; D d; d._a = 1; B* pb = &b; pb->_a++; pb = &d; pb->_a++; return 0; }
后面的四行代码想必我们都已经了解了,由于此处是菱形虚拟继承,所以pb在寻找a的时候会先通过偏移量来进行找到的,之后我们将pb接收d的地址,此处就是子类给父类的切割了。就是我们上面的样例了。也就是说他们寻址的底层都是一样的。都是通过偏移量去找到的,我们看下面的汇编,也会发现是一模一样的
也就是说,在这里的情景就是无论如何,编译器始终先取到偏移量,然后计算出_a的地址,最后在访问。