文章目录
- 前言
- 多继承
- 虚继承
- 虚继承的底层
- 组合
前言
上一篇文章我们C++的正常继承其实已经讲完了,但是后面还有一个大坑。
实际当中继承有单继承和多继承。
单继承就是直接继承一个类。
只有一个直接父类的就叫做单继承。
如果是单继承那就比较简单。
现实世界除了有单继承还有多继承。
多继承
多继承就是我一个类我具备另外两个类的特征。
单继承就是一个类只具备另外一个类的特征。
现实世界当中有什么东西需要具备两个特征都继承一下呢?
比如:有没有一种物种既具有水果的特征,也具有蔬菜的特征?
番茄。
多继承很重要,它能够更好的描绘这个世界。
所以多继承看起是很合理的,但是它有一个大坑。
多继承就可能会导致这种菱形继承。
菱形继承会有什么样的问题呢?
他会导致对象里面有两份人的信息。
这样你就不知道要访问从学生那里继承来的呢,还是从老师那里继承来的呢?
指定访问是能解决二义性的
但是这样很不合理的。
首先名字肯定有一个正式的名字比较合理,另外如果有其他的一些信息,
你肯定会觉得很冗余,比如
数据冗余的本质是空间浪费
所以不要搞出菱形继承,非常非常坑。
虚继承
菱形继承怎样解决数据冗余和二义性呢?
这里引入了一个新的东西,虚继承。
它们都变成了同一个。所以也得出一个结论,监视窗口看到的不一定是真实的,
它们都是被处理过的。
从实际的角度,可以用多继承,但是不要用菱形继承。
但是从学习的角度,我们还得学一下这个菱形继承。
这样是不是菱形继承?
是的,它都有数据冗余和二义性。
虚继承的底层
虚继承是如何解决数据冗余和二义性的?
虚继承虽然解决了数据冗余和二义性,但是这个过程是很难看的。
监视窗口已经看不出它最真实的面目。它的底层不是这样的。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
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; // 主修课程
};
void Test()
{// 这样会有二义性无法明确知道访问的是哪一个Assistant a;a._name = "peter";// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}int main()
{Test();return 0;
}
这里虽然有三个_name,但是每个_name都是一样的。
它的底层到底是怎么样呢?
物理上它的底层到底是怎么样呢?这里要换一个角度去看,
监视窗口已经看不到真实的东西,我们这里看一下内存窗口,内存窗口是真实的,不加修饰的。
我们这里用一个简化的类模型,方便我们看。
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;
};
ABCD依次构成继承。
看它的底层是什么样的,我们先看不看虚继承,就看菱形继承。
接下来我们看虚继承。
对象模型相比刚才已经发生了本质的变化。
注意看这里面还有我们不认识的两个东西,这些东西是什么?
00 aa cd 8c
00 aa cb ac
这两个有点像指针,而且距离也不远
它们具体是什么呢,我们再用一个内存窗口观察一下。
注意这是小端,然后把它调成4,因为编译器是32位的,指针也是32位的,并且存的是整型,正好对齐方便观察。
这是怎么回事,这个地址指向的空间是个0, 并且下面有一个值。
这个值到底是什么?
所以这两个值是偏移量,也是相对距离。
之前是B里有一个a,C里也有一个a,那这就造成数据冗余和二义性。
现在把一个a放到公共的空间去,然后通过偏移量去找。
那现在问题来了,为什么不直接存a的地址呢?
直接存a的地址,这样不是更好吗?
大家注意,这个偏移量它没有存到第一个位置,这个位置是空出来的,为以后的
多态做准备。
如果这里存a地址,只解决了一个问题。而如果这个地方存放一个指针,指向一个表,这张表
可以存很多其他信息。
什么情况会涉及刚才这样一块问题呢?
以前的赋值兼容转换直接切割就可以了,现在直接切父类没毛病,但是不完整,还有一个a.
这个a在哪呢,在切的时候就涉及一个问题,找到对应的a。
怎么找呢?拿到对应的偏移量,然后计算才能找到a.
第二种就更复杂了。(这里还是比较难的)
ptrc指向哪里?
不是指向最开始的地方,多继承指针会发生偏移。
虚继承还有更复杂的问题。b对象的对象模型是什么样的?
按照我们以前的理解,b里面有一个_a,有个一个_b,现在实际并不是这样。
虚继承影响了这块。它要保持一致。
也就意味着这里面有两种情况,这两个代码看起来一样,实际上跑起来天差地别。
一个指向d对象,一个指向b对象。它们的偏移量也是不一样的。
但是它们的汇编指令是一样的。
它们去访问a是一样的,都是找第一个位置的地址。拿这个地址找到指向的表,
找到偏移量,然后计算找到a.
验证一下上面说的
一个是普通赋值,一个是切片
这样它的模型就对上了,它不需要区分是子类对象还是父类对象,它的动作是一样的。
汇编指令是一样的。
虚继承不是要解决数据冗余的问题吗?怎么还变大了?
这块变大了,是因为a太小了。它要解决数据冗余是有成本的,增加两个指针。
但是这个成本是固定的,而这个数据的大小是不确定的。
为什么不需要考虑指向的空间?
一个类可能定义很多很多对象,每个对象空间都要多8个字节,
但是这个空间不是每个对象独立的,是共同分担的,因为它们的偏移量是不变的。
真正的消耗并不在这里。
自己可以单独去验证一下。
小问题
1.如果A有多个成员,需不需要增加指针?
不需要,首先偏移量不会变,并且它是按照声明顺序去访问的。
内存对齐并不会影响这个。
举个例子。ptr是如何访问这些成员的?
这里也有内存对齐。编译器也是根据内存对齐的规则去算的。
写编译器的人真的是高手中的高手
虚继承是有一定的效率损失的。
在实际当中我们不要去玩菱形继承,效率上有损失而且出问题了很难分析。
看一下下面这道题,结果是什么,看一下你还想不想玩菱形继承。
这道题也没什么,就是在虚继承的基础上加入了构造函数。
这里打印顺序是什么?
这里面调用三次A的构造函数,难道打印了三次A吗?
打印三次A就意味着A被初始化三次。看起来好像这样。
编译器肯定做了很多特殊处理。
A的构造函数实际应该是调用一次,因为只有一份A.
现在还有一个问题,调的这个A,是B里的A,还是C里的A,还是单独的A?
肯定是D里面单独调用这个A最好。外面单独去搞更好。
这里面的运行顺序是怎样的呢?
为什么先调A?
因为初始化列表初始化的顺序跟出现的顺序无关,跟声明的顺序有关,
谁先声明谁先初始化。
注意,谁先被继承谁就先声明
这样出题难度更大。
实际结果没有变。
组合
什么是组合?举个例子。
D想复用C,可以像上面这样复用。
单从关系来说AB的继承关系更紧密一些,还是CD的组合关系更紧密一些?
也就是耦合度,继承的耦合度更高一些,为什么?
实际当中组合更好
我们之前的适配器就用了组合。
为什么还要用继承?
有些关系适合继承那就用继承。另外多态的基础必须是继承。
两个都可以那就用组合,适合用继承就用继承,
适合用组合就用组合。
容器里面有没有迭代器?
容器里面是没有迭代器的,除了vector.
容器只是通过begin()去获取那个位置的迭代器,并不是里面有。