《C++继承深度引擎:从内存布局到多态全景拆解》
前引: “继承不仅是代码复用的工具,更是C++对象模型的神经中枢
本文以汇编视角穿透vptr
构造,解析虚表触发机制,结合内存偏移量化访问控制,
揭示多重继承中的Thunk
跳板秘密。通过手写VTAB模拟与clang -fdump-record-layouts
实测,
助你构建编译器的上帝视角——从此,dynamic_cast
不再神秘,菱形继承沦为可控拼图
目录
【一】继承介绍
【二】继承定义
【三】继承关系
【三】秒解继承关系
【四】例如:public公有继承
基类的public成员
解释
【五】基类和派生类对象赋值转换
(1)基类->派生类的转化
(2)基类->派生类的转化类型
(3)同名函数/变量
(4)如何在派生类初始化基类成员
【六】基类/派生类调用顺序
【七】派生类的默认成员函数
(1)构造函数
(2)拷贝构造
(3)析构函数
【八】继承与友元
【九】继承与静态成员
【十】单继承
【十一】多继承
特殊情况:菱形继承
原理说明:虚继承
原理解释:
【一】继承介绍
继承(Inheritance)是面向对象编程(OOP)的核心概念之一
它允许我们定义一个新类(派生类 Derived Class) 在已有类(基类 Base Class) 的基础上建立
派生类自动获得(继承) 基类的所有数据成员(属性)和成员函数(方法),并可以添加新的成员,或重新定义(覆盖/重写)继承来的成员函数以改变其行为
目的:实现代码复用(Reusability) 和建立类之间的层次关系(Hierarchy)
例如定义老师、学生、家长的信息类:
老师:姓名、年龄、电话、工号、职务
学生:姓名、年龄、电话、学生号
家长:姓名、年龄、电话、工作
它们有很多地方是高度相似的,我们就可以把这种经常重复的信息抽取出来,直接复用——继承
【二】继承定义
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类
如图所属关系:
【三】继承关系
我们根据继承的定义应该不难理解,其实就是“复用”信息,复用的信息是根据修饰符来划分的,在类里面,有这三种限定符:public、private、protected
public
公有继承: (最常用)
- 基类的
public
成员 → 在派生类中仍然是public
- 基类的
protected
成员 → 在派生类中仍然是protected(可访问不可修改)
- 基类的
private
成员 → 在派生类中不可直接访问(存在但不能用)protected
保护继承:
- 基类的
public
成员 → 在派生类中变为protected
- 基类的
protected
成员 → 在派生类中仍然是protected
- 基类的
private
成员 → 不可直接访问private
私有继承:
- 基类的
public
成员 → 在派生类中变为private
- 基类的
protected
成员 → 在派生类中变为private
- 基类的
private
成员 → 不可直接访问
继承后基类的Person的成员(成员函数+成员变量)都会变成派生类的一部分
【三】秒解继承关系
下面是全部的继承关系:
可以看到:
(1)基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它
(2)基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
(3)实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他 成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式) public > protected > private。例如:
基类的公有成员,被派生类公有继承->变成派生类的公有成员
基类的保护成员,被派生类保护继承->变成派生类的保护成员
基类的保护成员,被派生类私有继承->变成派生类的私有成员
(4)使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
【四】例如:public公有继承
基类的public成员
例如:现在有一个基类,它有三种访问限定方式:
//基类
class Person
{
public:void text1(){cout << "public" << endl;}
protected:void text2(){cout << "protected" << endl;}
private:void text3(){cout << "private" << endl;}
};
现在我们继承基类成为了新的派生类 Giant:
//派生类
class Giant:public Person
{};
效果展示:(这里只能调用派生类的text1(),而text2和text3受限定符保护)
解释
在基类中,有三种访问限定方式,这时它们都被派生类公有继承:
基类中原来的公有成员,到了派生类中->还是派生类的公有成员
基类中原来的保护成员,到了派生类中->变成了派生类的保护成员
基类中原来的私有成员,到了派生类中->不可见(仅可在基类内使用)
【五】基类和派生类对象赋值转换
(1)基类->派生类的转化
继承中,派生类可以转化为基类,基类不可以转为派生类,原因:
派生类的成员是一定包含基类(大转小),而基类的成员对比派生类差了太多(小转大)。派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。例如:
(2)基类->派生类的转化类型
派生类转化为基类是强转还是隐式类型的转换?例如这里分别有一个基类,一个派生类
现在我们对它们进行取引用转化:派生类->基类,因为引用不能赋值与临时对象,所以我们来检验
下面我们看转化效果:
(3)同名函数/变量
在基类和派生类中可能出现同名的函数或者变量,例如下面这样,我们看调用时有什么效果:
这里为了方便演示,我将派生类的Age设置为公有,下面我们看调用效果:
结论:出现同名函数/变量,优先查找自己类域的,再去考虑基类(就近原则)
原因:继承体系中基类和派生类都有独立的作用域
子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义
如果我们想在派生类中调用基类的同名成员,可以使用:类名::成员
注意:同名成员可能会被认定为“重载”,重载的要求是同一个作用域,这里是不同的作用域
在实际中在继承体系里面最好不要定义同名的成员
例如:
(4)如何在派生类初始化基类成员
首先下面这种是不可以的,因为两个是不同的类域,直接初始化基类没什么太大的关系
我们可以通过匿名对象去调用对应类的构造函数去初始化:派生类中使用基类的匿名对象
效果展示:
【六】基类/派生类调用顺序
现在有下面这样的基类和派生类,现在我们去实例化派生类,看它的调用顺序是怎样的?
不管在派生类的初始化列表中是如何排布的,它都是先调用基类的构造,再回到派生类继续构造
【七】派生类的默认成员函数
“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢?
对象构造时序规则
- 基类优先构造原则:当创建派生类对象时,必须先完整构造基类子对象,然后才能构造派生类自身成员
- 初始化列表的唯一时机:初始化列表是唯一能指定基类构造方式的阶段
(1)构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
总结:
(1)派生类的构造必须调用基类的构造函数初始化基类
(2)如果基类没有默认的构造函数,则必须在派生类的初始化列表中显示调用基类的构造
(3)如果基类有默认的构造函数,则可以不显示调用,因为编译器会调用基类的默认构造函数
(2)拷贝构造
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化(各干各的)
理解:现在是建设阶段各建设各的,基类和派生类的对象才是一个完整的房子
而基类应该在派生类前面去建造,所以我们应该先调用基类的拷贝构造,这里问题来了:
这两种:初始化列表和函数体调用基类的拷贝构造选哪一种?
答案啊:选择初始化列表(只有初始化列表才能指定基类的构造方式)
所以拷贝构造我们可以这么写:
//拷贝构造
Student(const Student& Ptr):Person(Ptr),Phone(Ptr.Phone)
{}
(3)析构函数
析构函数的调用我们不用管,由编译器自己调用
那么析构是先子后父?还是先父后子?
分析:我们的派生类是复用了基类的成员的,所以基类可以理解为地基,那么就名正言顺的 应该先析构派生类的,再去除掉地基的(先子后父)
【八】继承与友元
规则:友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
【九】继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子 类,都只有一个static成员实例。例如我在基类里面定义了一个静态成员,现在构造出2个子类对象
可以看到 “date” 没有从0开始,而是在第一个对象的基础上继续变化的
注意:基类中定义静态成员,子类对象继承了基类,但是无法继承它的静态成员
【十】单继承
说明:一个派生类只有一个基类的继承关系
例如:
class Person1
{;
};class Person2 :public Person1
{;
};class Person3 :public Person2
{;
};
【十一】多继承
说明:一个派生类有两个及以上的基类的继承关系
例如:
class Perosn1
{;
};class Person2
{};class Person3 :public Perosn1 , public Person2
{};
特殊情况:菱形继承
说明:菱形继承是多继承中最多继承的一种特殊情况
例如:
class Perosn
{
public://名字string name;
};class Student :public Perosn
{
public://学生编号int age;
};class Teacher :public Perosn
{
public://职工编号int id_;
};class C :public Student, public Teacher
{
public://电话号码vector<int> datebase;
};
注意:这样会有一个Bug,导致最终拥有同一个基类,基类的成员被二次重复了,到底用哪个?
如果我们去使用它里面的最终的这个基类的成员,会报错,例如:
解决方法:使用虚继承(需要注意的是,虚拟继承不要在其他地 方去使用)
原理说明:虚继承
现在有这样一个菱形继承(虚继承):
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;
};
虚拟继承的核心思想是:
(1)让顶层基类(虚基类) Person 在整个继承体系中只保留一个共享副本
(2)并由最底层的派生类 C 负责直接初始化它
使用虚继承时:
编译器会确保在整个继承路径中,Person 类型的子对象只存在一个实例
那么编译器是如何确保一个最终派生类只有一个顶层基类的?
虚基类表 (
vbtable
) 和虚基类指针 (vbptr
)
- 为了在运行时找到这个共享的 Person 子对象的位置,编译器会为使用了虚继承的类(这里是 Student 和 Teacher)生成一个:虚基类表
- 在类 Student 和类 Teacher 的对象中,编译器会添加一个隐藏的指针成员:虚基类指针
- 虚基类指针 指向 Student 或 Teacher的 虚基类表(通常是类第一个虚基类或其他虚基类指针的位置偏移信息)
原理解释:
首先我们对每个变量都进行赋值,方便进行观察
D d;d.B::_a = 1;
d.C::_a = 2;d._b = 3;
d._d = 4;
菱形继承:
现在我们先不使用虚继承,此时B和C都各有一个A,我们看看V的变量内存分布:
可以看到是有两个a的,这就是二义性
虚继承:
现在我们使用虚继承再来看看V的变量内存分布:
可以看到02和03上面都有一个很奇怪的数字——虚基类指针
虚继承里面把a单独拿出来了,这就是虚继承和菱形继承的区别!
虚继承里面我们可以看到它是把 a 单独拿出来了,此时 a 不属于B也不属于C
为什么不直接存a的地址,反而存a的偏移量呢?
我们再创建一个对象,将上面的两个虚基类指针用内存打开,可以看到下面这样的显示:
结论:对于同一个对象类型,它的偏移量是相同的
如果我们直接把虚基类指针换成 a 的地址:当创建多个对象时,每个 a 的地址是不同的,那么会增加类的大小和复杂度,用偏移量的化就可以通过“+”偏移量来确定 a 的位置,例如:
总结:原来菱形继承 公共a 的位置都换成了虚基类指针,通过虚基类指针查看右边的虚基表获取偏移量,左边在通过“+”偏移量直接查看公共 a (这样避免多次存地址带来的效率低)
·
虚继承的特点可以保证每次直接在对象的存储末尾查看,不需要去再查看偏移量!
右边的第一行之所以都为0:为了给后面的多态准备
如果创建一个B对象,一个D对象,那么是如何确定是不同的_a呢?
首先它们的汇编都是一样的:
比如B对象,此时它是虚继承的组成部分;还有一个D对象;此时它们都有一个 a
通过“+”偏移量来确定 a 的不同,例如,B和D是两个不同的对象类型,它们的偏移量不同: