C++的类中的虚拟继承【底层剖析(配图解)】
文章目录
- 前言
- 1. 继承的形式
- 1.1 单继承
- 1.2 多继承
- 2. 菱形继承
- 2.1 菱形继承的产生
- 2.2 virtual虚拟继承现象和语法
- 2.3 虚拟继承的底层
- 2.3.1 单虚拟继承
- 2.3.2 虚拟继承 — 虚基表、虚基表指针
- 2.3.3 菱形虚拟继承的底层分析
- 2.4 小结
前言
关于菱形继承和虚拟继承这个话题是C++学习者必须经历的一条路。小编的这篇文章将从:
- 继承的类型
- 菱形继承的问题
- 虚拟继承是如何解决菱形继承的问题的?
- ……
角度出发,带大家对虚拟继承的底层实现方式有一定的了解!
相信各位读者在阅读之后一定会大有收获!
1. 继承的形式
在之前小编和大家探讨基础语法的是时候列出了许多的示例,但是这些示例都是一些单子单父的情况。小编也在语法的时候提到一句:一个派生类可以继承多个基类。所以这也让我们的继承方式变得多样起来。
1.1 单继承
- 单继承:一个子类只有一个直接父类的继承关系为单继承
如下图:
1.2 多继承
- 多继承:一个子类有两个及以上的直接父类的继承关系被称为多继承。
如下图:
不管继承方式是单继承还是多继承,我们都应该注意到:我们对待这个子类为一个新的类型,一定要注意好如何设计好这个类的构造、析构等函数,以及各种接口!
2. 菱形继承
2.1 菱形继承的产生
刚刚了解到单继承和多继承的形式,我们可以想到这样一个场景:我现在有一个person
类来描述一个学校的人,那么我用student
类继承这个person
类,再由teacher
类继承这个person
类。我们学校有助教(assistant
类),助教既是一个学生,也是一个老师……我们可以采用assistant
继承person
和teacher
……
大概继承关系如下图:
class person
{};class student : public person
{};class teacher : public person
{};class assistant : public student , public teacher
{};
观察这个图像的形状——一个菱形。这就是菱形继承名字的由来,这样的继承体系的结构十分地像一个菱形,所以被命名为菱形继承。
我们现在对这个菱形继承的一些成员和方法进行完善:
例1:
下面我们为这个继承关系中的类,进行一些字段的填写:
#include<iostream>
#include<string>
using namespace std;
class person
{
public://……
protected:string _name = "zhangsan"; //姓名int _ID = 1; //身份证
};class student : public person
{
public:void study() //学习方法{}
protected:int _sid = 2; //学生id
};class teacher : public person
{
public:void teach() //教学方法{}
protected:int _tid = 3; //教师id
};class assistant : public student, public teacher
{
public:void check() //助教的方法{}
protected:int _age = 4; //年龄—这里是故意将年龄在这里充当一个字段的
};int main()
{assistant a;cout << "DEBUG" << endl;return 0;
}
来看这个继承体系的继承模型:
很显然,我们可以得到如下结论:
-
person
的字段在assistant
中有两份。person
类被student
类和teacher
类继承下来,对于student
类和teacher
类都有一份person
类的字段,那么再由assistant
继承他们,那么就导致了assistant
就有了两份person
类的字段!这就导致了一个来自菱形继承的问题:
-
数据冗余和二义性。
- 数据冗余:两份来自于基类
person
的字段。 - 二义性:派生类在访问基类字段的时候,是访问的基类
student
还是基类teacher
呢?
- 数据冗余:两份来自于基类
-
我们从内存角度看看这个存储结构:
完全符合我们的模型和预期!!!
我们从观察内存存储角度出发,发现这个菱形继承的问题所在!
2.2 virtual虚拟继承现象和语法
那么我们应该如何去解决这个问题呢?
我们现在需要解决的问题:
- 菱形继承的二义性。
- 菱形继承的数据冗余。
菱形继承的二义性我们可以通过指明类域的方式来进行分别。
如果是我们设计这样的一个方法来解决这样的问题,我们大概会怎么样去解决呢?
既然这个样的一个
assistant
中有两份的person
对象,那么我们想办法只保留一份即可。
C++处理二义性和数据冗余的核心也是将这样的数据保留一份即可!
- 关键字:
virtual
- 使用:用于在分支子类的地方上。例如:上面的例1的
student
和teacher
。 - 下图标红的类
例2:
#include<iostream>
#include<string>
using namespace std;
class person
{
public://……
protected:string _name = "zhangsan";int _ID = 1;
};class student : virtual public person
{
public:void study(){}
protected:int _sid = 2;
};class teacher : virtual public person //虚拟继承
{
public:void teach(){}
protected:int _tid = 3;
};class assistant : public student, public teacher
{
public:void check(){}
protected:int _age = 4;
};int main()
{assistant a;cout << "DEBUG" << endl;return 0;
}
上面的代码,使用了
virtual
关键字。由student
和teacher
虚拟继承person
,这就是“虚拟继承”
这里,我们从内存存储角度来看,菱形继承的问题是否得到了解决呢?
如下图:
- 现象和问题:
- 这样的结果完美符合我们想要的预期—最后的
assistant
类只保留了一份来自于person
的字段。 - 有两个字段:
002df1d0
和002dec14
是什么呢? person
字段被保存到了高地址处。整个对象的最高地址处。这是为什么呢?
接下来,小编和大家一起分析这个虚拟继承的底层。
2.3 虚拟继承的底层
在分析上面问题之前,我们先来谈一个单独继承的模型!
- 注意到上面的
student
和teacher
字段:他们的首地址的四字节(X86环境下)处都是一个“看起来的乱码”。这就是C++解决虚拟继承的关键。
问题又来了:当我们这样处理的时候,对于这样的场景我们应该如何去查找对应的分支处基类的字段呢?
例3:
#include<iostream>
using namespace std;class A
{
public:int _a = 1;
};class B : virtual public A
{
public:int _b = 2;
};class C : virtual public A
{
public:int _c = 3;
};class D : public B, public C
{
public:int _d = 4;
};int main()
{D d;B* ptr1 = &d;ptr1->_a; //语句一C* ptr2 = &d;ptr2->_a; //语句二return 0;
}
上面的代码中,语句一和语句二是如何找到这个共同的
_a
呢?
我们稍后解决……
2.3.1 单虚拟继承
虚拟继承都是用于多继承的情况下,解决数据冗余问题。不过小编为了从简单了解,所以直接采用单继承的虚拟继承原理开始谈起。
我们从简单的一个单继承的虚拟继承模型开始谈起。
例4:
#include<iostream>
using namespace std;
class A
{
public:int _a = 1;
};class B : virtual public A
{
public:int _b = 2;
};int main()
{B b;cout << "DEBUG" << endl;return 0;
}
来看内存存储:
-
这就是一个指针字段。地址为:0x00299b30
-
来看这个字段包含的东西:
-
进一步思考:0x012FFA34 - 0x012FFA2C == 8(十六进制)
-
答案呼之欲出了:
这个字段就是一个指针。这个指针指向了一个表,这个表存放着所继承下来的父类存放在当前指针位置的偏移量!
2.3.2 虚拟继承 — 虚基表、虚基表指针
接下来为上面的答案揭秘
- 实际上,通过virtual虚拟继承,我们将分支的父类的成员变量放在了一个公共的区域。
- 对于原来的对象,我们需要找到这个公共的区域是需要通过偏移量的。而这个偏移量被存储在了一个公共的区域形成了一张表,该表被称为:虚基表。而这个首字段的指针指向这个表,被称为:虚基表指针。
- 这个存储模型如下:
这就是上面的结果!!
现在我们了解到了单继承下的虚拟继承行为,那么多继承就是利用了这样的方式来解决问题!我们迁移到多继承的条件下!
2.3.3 菱形虚拟继承的底层分析
现在回到例3的场景
#include<iostream>
using namespace std;class A
{
public:int _a = 1;
};class B : virtual public A
{
public:int _b = 2;
};class C : virtual public A
{
public:int _c = 3;
};class D : public B, public C
{
public:int _d = 4;
};int main()
{D d;B* ptr1 = &d;ptr1->_a; //语句一C* ptr2 = &d;ptr2->_a; //语句二return 0;
}
我们如何来分析,编译器是如何通过语句一、语句二来找到_a的呢?
-
小编直接给出这个问题的内存存储结构和抽象模型:
- 内存存储结构:
- 抽象模型:
箭头代表从指针开始找到虚基表,再找到基类成员
- 内存存储结构:
D d;B* ptr1 = &d;ptr1->_a; //语句一C* ptr2 = &d;ptr2->_a; //语句二
-
再来回答上面的问题:语句一(
ptr1->_a;
)和语句二(ptr2->_a;
)是如何找到对应的_a
成员的呢?- 由于
ptr1,ptr2
是B
类和C
类的指针。访问_a
的时候,识别到虚基表指针,找到虚基表指针指向的虚基表,得到A
类对象的偏移量,最后通过偏移量找到A
类对象距离当前指针位置的地址,最后成功访问到A
类对象。
- 由于
现在,我们再来看之前的例2:
#include<iostream>
#include<string>
using namespace std;
class person
{
public://……
protected:string _name = "zhangsan";int _ID = 1;
};class student : virtual public person
{
public:void study(){}
protected:int _sid = 2;
};class teacher : virtual public person //虚拟继承
{
public:void teach(){}
protected:int _tid = 3;
};class assistant : public student, public teacher
{
public:void check(){}
protected:int _age = 4;
};int main()
{assistant a;cout << "DEBUG" << endl;return 0;
}
你能说清楚这个例子的存储模型是如何存储的吗?
2.4 小结
-
首先来小结一下菱形虚拟继承到底是如何进行的:
- 将公共继承的父类,存放到一个共同的区域。
- 将原有的父类内容改作一个虚基表指针。
- 这个虚基表指针指向这个共同区域,虚基表。
- 这个虚基表存放在当前对象到父类对象的偏移量。
- 通过偏移量找到父类对象。
- 访问父类对象。
小编再次提出一个问题:
-
这个虚基表和偏移量的意义是什么呢?
- 对于不同的同类对象,可以共用一张虚基表。
- 来看下面的输出:
但是如果你细心的话,你会发现:在D
这个对象中B
和C
对象都是分裂的!即所占用的空间不是连续的空间了!
所以如果不通过偏移量这样的一个统一的模型,编译器是无法找到对应的A
类对象是在那个位置的,也就是无法访问成功。
结论:偏移量为访问基类成员提供了统一的方法:先找偏移量再找成员。
完。
希望这篇文章能够帮助你!