06.C++ 继承与多态
本节分为十部分:
- 继承的基本意义
- 派生类的构造过程
- 重载、覆盖、隐藏
- 虚函数、静态绑定和动态绑定
- 虚析构函数
- 理解多态是什么
- 理解抽象类
- 理解虚基类和虚继承
- 菱形继承问题
- C++ 的 四种类型转换
1. 继承的基本意义
继承的本质:
代码的复用,实现多态的基础。
关系:
- 组合:a part of… 一部分的关系
- 继承:a kind of… 一种的关系
继承方式和访问限定罗列:
继承方式 | 基类的访问限定 | 派生类的访问限定 | (main)外部的访问限定 |
public | |||
public | public | Y | |
protected | protected | N | |
private | 不可见的 | N | |
protected | |||
public | protected | N | |
protected | protected | N | |
private | 不可见的 | N | |
private | |||
public | private | N | |
protected | private | N | |
private | 不可见的 | N |
protected继承方式下: 基类的成员的访问限定,在派生类里面是不可能超过继承方式的。public降为protected的。
private继承方式下: public与protected降为private。
总结:
- 外部只能看到并使用 public 成员,protected 和 private 成员无法直接访问。
- 在继承结构中,派生类从基类继承来的 private 成员,然是无法直接访问(派生类中不可见)。
- 基类成员访问限定,在派生类里面不能超过继承方式。
protected 和 private 区别:
- 如果只是单纯的一个类,没有继承结构,则二者没有区别,外部都是无法访问的。
- 若存在继承关系,在基类中定义的成员,想被派生类访问,但不想被外部访问,那么可以在基类中将该成员定义为保护型。
- 若存在继承关系,在基类中定义的成员,不想被派生类访问,也不想被外部访问,则可以在基类中将该成员定义为私有。
默认继承方式:
- 要看派生类是用 class 定义的,还是 struct 定义的:
- 若 class 定义派生类,则默认继承方式是私有的。
- 若 struct 定义派生类,则默认继承方式是共有的。
代码:
1 |
|
2. 派生类的构造过程
派生类从基类可以继承来所有的成员(成员变量和成员方法)。
派生类如何初始化基类继承的成员变量:
通过调用基类相应的构造函数来初始化
- 派生类的构造函数和析构函数,负责初始化和清理派生类部分;
- 派生类从基类继承来的成员的初始化和清理由基类的构造函数和析构函数来负责。
派生类对象构造和析构过程:
- 派生类调用基类的构造函数,初始化从基类继承来的成员。
- 培生类调用自己的构造函数,初始化自身的成员。
- ……….. 派生类对象的作用域到期。
- 派生类调用自己的析构函数,释放派生类成员可能占用的外部资源(堆内存、文件)。
- 系统自动调用基类的析构函数,释放派生类内存中从基类继承来的成员可能占用的外部资源(堆内存、文件)。
代码:
1 |
|
3. 重载、覆盖、隐藏
继承结构中,名字相同的成员会产生关系,基类派生类里面相关的成员方法我们经常使用三种关系来描述它们,即:重载、隐藏、覆盖关系。
- 重载:
一组函数要重载,必须处在同一个作用域当中;而且函数名字相同,参数列表不同。
- 隐藏:
在继承结构当中,派生类的同名成员,把基类的同盟成员给隐藏掉了,即作用域的隐藏。
- 覆盖:
父子类中的同名、同参数、同返回值的多个成员函数,从子到父形成的关系称为覆盖关系,在虚函数中会详谈它。
1. 隐藏关系
1 |
|
派生类对象调用了不带参数的show方法,派生类本身没有这个方法。
执行结果:调用成功,而且1与2是函数重载关系。
我们又给派生类添加了一个不带参数的show方法:
1 |
|
派生类调用 show 时优先调用自己的 show()
,在自己的作用域下找相应的成员名字,若没有才去基类找;当我们调用带整数参数的 show(int)
,自己并没有,从基类继承来一个带整型参数的 show(int)
。
执行一下:第二个 show(int)
调用执行出错。1 与 3 属于隐藏关系;2 与 3 也属于隐藏关系。
第二个调用的是派生类的 show()
,不接受参数。
为什么不调用基类中的 show(int)
?
派生类对象调用派生类与基类同盟成员时,派生类已经有一个名字 show 了,派生类的 show()
将基类同盟 show()
隐藏掉了。
如果想调用基类成员函数,必须指明基类的作用域来调用:
1 |
|
指明作用域调用成功。
2. 继承中的类型转换
继承结构,我们也称为从上(基类)到下(派生类)的结构。
类型转换是否可以?
- 基类对象——>派生类对象的转换:类型从上到下的转换(NO)
1 |
|
执行结果:执行失败。
- 派生类对象——>基类对象的转换:类型从下到上的转换(YES)
1 |
|
执行结果:成功执行。
- 基类指针(引用)——>指向派生类对象:类型从下到上的转换(YES)
1 |
|
执行结果:成功执行。
- 派生类指针(引用)——>指向基类对象:类型从上到下的转换(NO)
1 |
|
执行结果:执行失败。
总结:
在继承结构中进行上下的类型转换,默认只支持从下到上的类型的转换。除非进行强转,但强转不安全会涉及内存的非法访问。
4. 虚函数、静态绑定和动态绑定
覆盖:
如果派生类中的方法和基类继承的某个方法,返回值、函数名、参数列表都相同,而且基类的方法是 virtual 虚函数 ,那么派生类的这个方法,自动被处理成虚函数,它们之间成为覆盖关系;也就是说派生类会在自己虚函数表中将从基类继承来的虚函数进行替换,替换成派生类自己的。
静态绑定:
编译时期的多态,通过函数的重载以及模板来实现,也就是说调用函数的地址在编译时期我们就可以确定,在汇编代码层次,呈现的就是 call 函数名。
动态绑定:
动态时期的多态,通过派生类重写基类的虚函数来实现。在汇编代码层次,呈现的就是 call 寄存器,寄存器的值只有运行起来才能确定。
1. 不存在的虚函数
1 |
|
反汇编查看:
可以看到调用的都是基类的 show()
,在编译阶段已经生成指令调用 Base 下的 show。
结果:
- 因为 pb 是 Base 类型的指针,所以调用的都是 Base 类的成员方法。
- 基类 Base 只有一个数据成员 ma,所以大小只有 4 字节。
- 派生类 Derive 继承了 ma,其次还有自己的 mb,所以有 8 字节。
- pb 的类型是一个
class Base *
; *pb
的类型是一个class Base
。
图示:
为什么 Base *
类型的指针,Derive 类型的对象,调用方法的时候是 Base 而不是 Derive 呢?
原因如上图:
Derive 类继承了 Base 类,导致了派生类的大小要比基类大,而 pb 的类型是基类的指针,所以通过 pb 调用方法时只能访问到 Derive 中从 Base 继承而来的方法,访问不到自己重写的方法(指针的类型限制了指针解引用的能力)。
2. 基类定义虚函数
1 |
|
反汇编:
pb->show();
中 pb 指针是 base 类型,如果发现 Base 中的 show 是虚函数,就进行动态绑定。
1 |
|
mov rax, qword ptr [pb]
将 pb 指向的内存前 4 个字节放入 rax 寄存器,pb指向derive对象,前四个字节即 vfptr
,将虚函数表地址加载到 rax
mov rax, qword ptr [rax]
将 rax 的前四个字节 即 Derive::show
加载到 rax 中。
call qword ptr [rax]
虚函数的地址
可以看到这一次,汇编码 call 的就不是确切的函数地址了,而是寄存器 rax。
那么就很好理解了:rax 寄存器里存放的是什么内容,编译阶段根本无从知晓,只能在运行的时候确定; 故为,动态绑定。
pb的类型:Base类型,查看Base中有没有虚函数
(1)Base中没有虚函数 *pb
识别的就是编译时期的类型 *pb
就是 Base 类型。
(2) Base中有虚函数,*pb
识别的就是运行时期的类型 RTTI 类型为:Derive。
在添加了 virtual 关键字后,对应的函数就变成了虚函数。
那么,一个类添加了虚函数,对这个类有什么影响呢?
- 如果类中定义了虚函数,那么编译阶段,编译器给这个类类型产生一个唯一的
vftable
虚函数表,虚函数表中主要存储的内容是:RTTI(Run-time Type Information)指针和虚函数的地址;当程序运行时,每一张虚函数表都会加载到内存的.rodata
区(只读数据区)。 - 一个类中定义了虚函数,那么这个类定义的对象,在运行时,内存中会多存储一个
vfptr
虚函数的指针,指向对应类型的虚函数表vftable
。 - 一个类型定义的 n 个对象,它们的
vfptr
指向的都是同一张虚函数表。 - 一个类中的虚函数个数,不影响对象内存的大小(vfptr),影响的是虚函数表
vftable
的大小。 - 如果派生类中的方法和从基类继承的某个方法中返回值、函数名以及参数列表都相同,且基类方法为 virtual ,那么派生类的这个方法自动被处理为虚函数。
虚函数表:
- RTTI:存放的是类型信息,也就是(Base 或 Derive)。
- 偏移地址:虚函数指针相对于对象内存空间的便宜,一般
vfptr
都在 0 偏移位置。 - 之后是函数时虚函数入口地址。
在 Derive 类中,由于重写了 show()
,因此在 Derive 的虚函数表中,是使用子类的 show() 方法代替了 Base 类的 show()
。
3. VS 工具查看虚函数表相关
- 找到
打开后:
- 在打开的窗口中切换到当前工程所在目录
- 输入指令
可通过 dir 命令查看当前目录所有文件
1 |
|
第一个XXX表示源文件的名字,第二个代表你想查看的类类型,我这里就是Derive
以看到 class Derived
的对象的内存布局,在派生类对象的开始包含了基类 Base 的对象,其中有一个虚表指针,指向的就是下面的Derived::$vftable@ (virtual function table)
,表中包含了 Derived 类中所有的虚函数。
5. 虚析构函数
析构函数:可以成为虚函数,调用时候对象存在。
虚析构函数:在析构函数前加上 virtual 关键字。
什么时候需要把基类的析构函数必须实现成虚函数?
基类的指针(引用)指向堆上 new 出来的派生类对象的时候,delete 调用析构函数的时候,必须发生动态绑定,否则会导致派生类的析构函数无法调用。
1 |
|
没有调用派生类的析构函数,造成内存泄漏。
问题:
pb 的配型是 Base 类型,因此 delete 调用析构函数先去 Base 找 Base::~Base()
,对于析构函数的第哦啊用就是静态绑定,之间编译,没有机会调用派生类的析构函数,最后发生内存泄漏。
解决:
将基类的析构函数定义为虚析构函数,派生类的析构函数自动成为虚函数。 pb 的类型是 Base 类型,调用析构时去 Base 中找 Base::~Base
发现它为虚函数,发生动态绑定。派生类的虚函数表中:&Derive:: ~derive
,用派生类析构函数将自己部分进行析构,再调用基类的析构函数将基类部分析构。
注意:
构造函数不能为虚函数!!!
(1)从存储空间角度:
虚函数对应一个 vftable
,这个 vftable
其实是存储在内存空间的。问题来了,如果构造函数是虚的,就需要通过 vtable
来调用,可是对象还没有实例化,也就是内存空间还没有,怎么找 vtable
呢?所以构造函数不能是虚函数。
(2)从使用角度:
虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义。所以构造函数没有必要是虚函数。
(3)虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
(4)构造函数不需要是虚函数,也不允许是虚函数,因为创建一个对象时我们总是要明确指定对象的类型,尽管我们可能通过实验室的基类的指针或引用去访问它。但析构却不一定,我们往往通过基类的指针来销毁对象。这时候如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
(5)从实现上看,vbtable
在构造函数调用后才建立,因而构造函数不可能成为虚函数
从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
6. 理解多态是什么
派生类对象的地址可以赋值给基类指针。对于通过基类指针调用基类和派生类中都有的同名、同参数表的虚函数的语句,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就叫作“多态(polymorphism)”。
所谓“虚函数”,就是在声明时前面加了virtual 关键字的成员函数。virtual 关键字只在类定义中的成员函数声明处使用,不能在类外部写成员函数体时使用。静态成员函数不能是虚函数。
包含虚函数的类称为“多态类”。
多态可以简单地理解为同一条函数调用语句能调用不同的函数;或者说,对不同对象发送同一消息,使得不同对象有各自不同的行为。
多态在面向对象的程序设计语言中如此重要,以至于有类和对象的概念,但是不支持多态的语言,只能被称作“基于对象的程序设计语言”,而不能被称为“面向对象的程序设计语言”。例如,Visual Basic 就是“基于对象的程序设计语言”。
1 |
|
程序中,四个类之间的派生关系如下所示:
每个类都有同名、同参数表的虚函数 Print(每个 Print 函数声明时都加了 virtual 关键字)。根据多态的规则,对于语句pa->Print()
,由于 Print 是虚函数,尽管 pa 是基类 A 的指针,编译时也不能确定调用的是哪个类的 Print 函数。当程序运行到该语句时,pa 指向的是哪个类的对象,调用的就是哪个类的 Print 函数。
多态的语句调用哪个类的成员函数是在运行时才能确定的,编译时不能确定(具体原理后面会解释)。因此,多态的函数调用语句被称为是“动态联编”的,而普通的函数调用语句是“静态联编”的。
1.多态起手式以及内存分布
假设有一个基类 ClassA,一个继承了该基类的派生类 ClassB,并且基类中有虚函数,派生类实现了基类的虚函数。
在代码中运用多态这个特性时,通常以两种方式起手:
(1) ClassA *a = new ClassB();
(2) ClassB b; ClassA *a = &b;
以上两种方式都是用基类指针去指向一个派生类实例,区别在于第 1 个用了 new 关键字而分配在堆上,第 2 个分配在栈上:
请看上图,不同两种方式起手仅仅影响了派生类对象实例存在的位置。
以左图为例,ClassA *a
是一个栈上的指针。
该指针指向一个在堆上实例化的子类对象。基类如果存在虚函数,那么在子类对象中,除了成员函数与成员变量外,编译器会自动生成一个指向 该类的虚函数表(这里是类ClassB) 的指针,叫作虚函数表指针。通过虚函数表指针,父类指针即可调用该虚函数表中所有的虚函数。
2. 类的虚函数表与类实例的虚函数指针
首先不考虑继承的情况;如果一个类中有虚函数,那么该类就有一个虚函数表。
这个虚函数表是属于类的,所有该类的实例化对象中都会有一个虚函数表指针去指向该类的虚函数表。
从第一部分的图中我们也能看到,一个类的实例要么在堆上,要么在栈上。也就是说一个类可以有很多很多个实例。但是!一个类只能有一个虚函数表。在编译时,一个类的虚函数表就确定了,这也是为什么它放在了只读数据段中。
3. 多态代码及多重继承情况
讨论了在没有继承的情况下,虚函数表的逻辑结构。
那么在有继承情况下,只要基类有虚函数,子类不论实现或没实现,都有虚函数表。
1 |
|
(1) ClassA 是基类, 有普通函数: fn1()、fn2()
。虚函数: vfn1()、vfn2()、~ClassA()
(2) ClassB 继承 ClassA, 有普通函数: fn1()
。虚函数: vfn1()、~ClassB()
(3) ClassC 继承 ClassB, 有普通函数: fn2()
。虚函数: vfn2()、~ClassB()
基类的虚函数表和子类的虚函数表不是同一个表。下图是基类实例与多态情形下,数据逻辑结构。注意,虚函数表是在编译时确定的,属于类而不属于某个具体的实例。虚函数在代码段,仅有一份。
ClassB 继承与 ClassA,其虚函数表是在 ClassA 虚函数表的基础上有所改动的,变化的仅仅是在子类中重写的虚函数。如果子类没有重写任何父类虚函数,那么子类的虚函数表和父类的虚函数表在内容上是一致的
1 |
|
使用 VS 开发者工具查看:
这个结果不难想象,看上图,ClassA 类型的指针 a 能操作的范围只能是黑框中的范围,之所以实现了多态完全是因为子类的虚函数表指针与虚函数表的内容与基类不同
这个结果已经说明了 C++ 的隐藏、重写(覆盖)特性。
同理,也就不难推导出 ClassC 的逻辑结构图了
类的继承情况是: ClassC 继承 ClassB,ClassB 继承 ClassA;这是一个多次单继承的情况。(多重继承)。
4. 多继承下的虚函数表 (同时继承多个基类)
多继承是指一个类同时继承了多个基类,假设这些基类都有虚函数,也就是说每个基类都有虚函数表,那么该子类的逻辑结果和虚函数表是什么样子呢?
1 |
|
(1)ClassA1 是第一个基类,拥有普通函数 func1()
,虚函数vfunc1()、 vfunc2()
。
(2)ClassA2 是第二个基类,拥有普通函数 func1()
,虚函数 vfunc1()、 vfunc2()、vfunc4()
。
(3)ClassC 依次继承 ClassA1、ClassA2。普通函数 func1()
,虚函数vfunc1()、vfunc2()、vfunc3()
。
在多继承情况下,有多少个基类就有多少个虚函数表指针,前提是基类要有虚函数才算上这个基类。
如图,虚函数表指针 01 指向的虚函数表是以 ClassA1 的虚函数表为基础的,子类的 ClassC::vfunc1()
,和 vfunc2()
的函数指针覆盖了虚函数表 01 中的虚函数指针 01 的位置、02 位置。当子类有多出来的虚函数时,添加在第一个虚函数表中。注意:
- 子类虚函数会覆盖每一个父类的每一个同名虚函数。
- 父类中没有的虚函数而子类有,填入第一个虚函数表中,且用父类指针是不能调用。
- 父类中有的虚函数而子类没有,则不覆盖。仅子类和该父类指针能调用
最后给出代码和结果:
1 |
|
7. 理解抽象类
抽象类(接口):接口描述了类的行为和功能,而无需完成类的特定实现
C++ 接口时通过抽象类实现的,设计抽象类的目的,是为了给其他类提供一个可以继承的适当的基类.抽象类本类不能被用于实例化对象,只能作为接口使用
注意:
如果试图实例化一个抽象类的对象,会导致编译错误。
因此,如果一个抽象类的派生类需要被实例化(建立对象),则必须对每个继承来的纯虚函数进行函数体实现。
如果没有在派生类中重写所有纯虚函数,就尝试实例化派生类的对象,也会导致编译错误,这是因为如果派生类没有实现父类的纯虚函数,则派生类变为抽象类。
抽象类基类为派生自抽象基类的派生类提供了约束条件,即:派生类必须要实现继承自抽象基类中的纯虚函数,否则此派生类不可进行实例化,且派生类将继承为抽象派生类。
1. 抽象类与纯虚函数(抽象方法)
纯虚函数是一个在 基类中声明的虚函数,它在该基类中没有定义具体的函数体(操作内容),要求派生类根据实际需要定义自己的版本,设计多层次类继承关系时用到。把某个方法声明为一个抽象方法等于告诉编译器,这个方法必不可少,但目前在基类中还不能为它提供实现
纯虚函数的标准格式:
1 |
|
1 |
|
注意:
一旦类中有了纯虚函数,这个类便被称为:抽象类。
且此类不能被实例化!!!(不可建立类对象实例!!!)
例如:
1 |
|
抽象类:
只能作为积累使用,无法定义抽象类对象实例,这是因为 抽象类中包含了没有定义的纯虚函数,在 C++ 中,我们把 只能由于被继承而不能直接创建对象的类 称之为:抽象类,这种基类不能直接生成对象,而只有被继承后,并重写其虚函数后,才能使用。
当抽象类的派生类实现了继承而来的纯虚函数后,才能实例化对象。
之所以要存在抽象类,最主要是因为它具有不确定因素,我们把那些类中的确存在,但是在父类中无法确定具体实现的成员函数称为虚函数。虚函数是一种特殊的函数,在抽象类中只有声明,没有具体的定义。
抽象类和纯虚函数的关系
抽象类中至少存在一个纯虚函数,存在纯虚函数的类一定是抽象类,存在纯虚函数是成为抽象类的充要条件。
2. 为什么需要一个抽象类
1 |
|
本例中定义了三个类,它们的继承关系为:Animal-->Cat
和Animal-->Dog
Animal 是一个抽象类,也是最顶层的基类,在 Animal 类中定义了一个纯虚函数sound()
,在 Cat
类中,实现了sound()
函数。所谓实现,就是定义了纯虚函数的函数体,抽象类 Animal 虽然不能实例化,但它为派生类提供了约束条件,派生类必须要实现这个函数,完成动物发声功能,否则就不能对派生类进行实例化。
在实际开发中,你可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁派生谁实现)。这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的,虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的“霸王条款”。
总结:
- 任何具有纯虚函数的类都是抽象类。
- 抽象类基类不可建立实例。
- 抽象类派生出的子类需将继承的纯虚函数全部进行实例化,才能建立其实例。
- 抽象类可以有构造函数。
- 如果派生类没有实现父类的纯虚函数,则派生类变为抽象类,即不可建立其实例。
- 抽象基类除了约束派生类的功能,还可以实现多态,可以创建指向子类的实例的抽象基类的指针和引用。
- 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数。
8. 理解虚基类和虚继承
virtual作用:
- virtual 修饰了成员方法是虚函数。
- 可以修饰继承方式,是虚继承。被虚继承的类就称为虚基类。
- vfptr:一个类有虚函数,这个类生成的对象就有 vfptr,指向 vftable。
- vbptr:在派生类中从基类虚继承而来。
- vftable:第一行为向上偏移量,第二行为虚基类指针离虚基类内存的偏移量。
- vbtable:存放的 RTTI 指针,指向运行时 RTTI 信息与虚函数地址。
1 |
|
这里我们的对象 a 占 4 个字节,对象 8 占 8 个字节。但如果我们给 B 的继承方式访问限定符前面加了一个 virtual 关键字。
1 |
|
类 A 被虚继承了,但内存布局没有变化:
类 B 在继承普通基类的内存变化:
再看一下类 B,不是之前的 8 个字节,变为 12 个字节,多了一个 vbptr 指针。原来最上面应该为 ma 与 mb,但是现在多了一个 vbptr
(虚基类指针),ma 跑到派生类最后面去了。vbptr
指向的是 vbtable
,vbtable
第一行为 0,第二行为虚基类指针到虚基类数据的偏移量。
当遇到虚继承时候,要考虑派生类 B 的内存布局时;
首先,先不考虑虚继承,类 B 继承了基类的 ma,还有自己的mb;当基类被虚继承后,基类变为虚基类,虚基类的数据一定要在派生类数据最后面,再在最上面添加一个 vbptr
。派生类的内存就由这几部分来构成。
虚基类指针(vbptr
)指向虚基类表(vbtable
),vbtable
第一行为向上的偏移量,因为 vbptr
在该派生类内存起始部分,因此向上的偏移量为 0;第二行为向下的偏移量(vbptr
离虚基类数据的偏移量),原来基类的数据放到最后,找 ma 的时候还是在最开始找,但 ma 被移动,根据偏移的字节就可以找到。
1. 虚基类和虚继承出错情况分析
那么当我们虚基类指针与虚函数指针在一起出现的时候会发生什么呢?
调用是没有问题的,但是 delete 会出错。
1 |
|
如图:调用成功,但delete时会出错。
分析:
B 的内存布局:
B 首先从 A 中获取 vfptr
与 ma, B 中还有自身的 mb;
此时 A 被虚继承,A 中所有的东西都移动到派生类最后面,最上面补一个vbptr
,vbptr
指向 vbtable
,vfptr
指向 vftable
;基类指针指向派生类对象,永远指向的是派生类基类部分数据的起始地址。
普通情况下,派生类内存布局先是基类,在是派生类,基类指针指向派生类对象时,基类指针指向的就是派生类内存的起始部分。但是,虚继承下基类称为虚基类,虚基类的数据在派生类最后面,原地方补上 vbptr
,此时再用基类指针指向派生类对象时候,基类指针永远指向派生类基类部分的起始地址。虚基类一开始就是 vfptr
,能够用 p 指向的对象访问 vfptr
与 vftable
的原因。释放内存时候出错,因为对象开辟是在最上面即绿色部分,但是 p 所持有的是虚基类的地址,delete 时从虚基类起始地址 delete,因此出错。
命令验证如下:
验证一下内存地址:
1 |
|
00000119E9411CB0
为分配的内存的起始地址,我们用基类指针指向派生类对象一定是指向派生类内存基类的起始部分:00000119E9411CC0
刚好比 00000119E9411CB0
多了 16 个字节,是 vbptr
与 mb,但是 delete 时候从 00000119E9411CC0
开始释放,因此崩溃。
Windows 的 VS 下这样写会出错,但是 Linux 下的 g++ delete
时会自动偏移到 new 内存的起始部分,进行内存 free()
,不会出错。
如果在栈上开辟内存,基类指针指向派生类对象,出了作用域自己进行析构,这样是没有问题的。
1 |
|
9. 菱形继承问题
多重继承: 一个派生类
如果只继承一个基类
,称作单继承; 一个派生类
如果继承了多个基类
,称作多继承。
优点:
- 多重继承可以做更多的代码复用!
- 派生类通过多重继承,可以得到多个基类的数据和方法,更大程度的实现了代码复用。
问题:
菱形继承的问题:在于继承时有重复利用的数据。
会导致派生类有多份间接基类的数据,可以采用虚继承来解决。 A 为 B、C 的基类,B 从 A 单继承而来,C 从 A 也是单继承而来;D 是 B 和 C 多继承而来,D 有两个基类分别为 B 和 C。A 称为 D 的间接基类,D 也有 A 的数据。
当然,多重继承还会出现别的问题:
半圆形继承问题: B从A单一继承而来,C有一个基类B而且同时还从A继承而来。A到B为单继承,C为多继承。
多重继承虽然可以复用多个基类的代码到派生类中,但是会出现许多问题,因此 C++ 开源代码上很少见到多重继承。
1 |
|
画一下 d 对象的内存布局:
D 能看见 B,C 与 md,所以 D 在构造时调用 B,C 的构造及 ma 的初始化。 ma 的初始化在 B 与 C 的构造函数中进行,因此 D 内存为 20 个字节。
输出结果:
先是 A 的构造,B 的构造,又是 A 的构造,C 的构造,最后是 D 的构造;析构顺序与其相反。就会发现,D 这个派生类中调用了两次 A 的构造,数据重复,浪费内存,这种情况必须被杜绝。
如何处理?
虚继承来处理,所有从A继承而来的地方都采用虚继承,A 就为虚基类。
此时:
B 从 A 虚继承而来,A 为虚基类,A::ma
移动到派生类最后面,在 A::ma
位置上补一个 vbptr
;C 也是从 A 虚继承而来,A::ma
移动到派生类最后面,但发现已经有一份同样的虚基类的数据,那么 C 的 A::ma
丢弃,在 A::ma
位置存放 vbptr
。此时派生类中只有一份基类 A::ma
的数据,以后访问都是同一个 ma;同时 ma 的初始化由 D 来负责。虚继承就可以解决多重继承中的菱形继承与半圆形继承出现的问题了。
1 |
|
打印结果:修改成功;A、B、C、D各初始化与析构一次。
10. C++ 的 四种类型转换
- const_cast
- static_cast
- reinterpert_cast
- dynamic_cast
1. const_cast
const_cast:修改类型的const或volatile属性。 使用该运算方法可以返回一个指向非常量的指针(或引用)指向,就可以通过该指针(或引用)对它的数据成员任意改变。
注意:
不考虑 const 或 valatile 后类型保持一致才可以进行合理的类型转换。
语法:
1 |
|
实例:
1 |
|
转换为相同类型的时候,通过反汇编查看时候,发现 C 中的类型强转与 C++ 中 const_cast
所生成的汇编指令底层是一模一样的。
但是在转换成汇编指令之前,即编译阶段,就有所不同。
注意:
- 不考虑 const 或 valatile 后类型保持一致才可以进行合理的类型转换。
1 |
|
通过 C中类型转换可以将 int *
转换为多种不同的类型,没有任何问题;这里为整型常量的地址,但是如果通过 const_cast
将整型常量地址转换为另一个指针类型不匹配的指针,是不可以的。const_cast
使用时,地址的类型是与左边类型以及转换的类型需要保持一致。防止了 C 中低级别的类型强转任意的转换指针的类型导致一些不确定的错误。
编译出错:
const_cast<里面必须是指针或引用类型>
,否则出错
1 |
|
编译出错:
2. static_cast
static_cast(静态)
:编译时期的类型转换,提供编译器认为安全的类型转换。
是一个 c++ 运算符,功能是把一个表达式转换为某种类型,使用最多。
注意:
- 有联系的类型之间可以互相转换。
- 没有任何联系的类型之前转换会被否定。
- 基类类型与派生类类型进行转换,可以用
static_cast
,它们类型之间有关系,但不一定安全。
实例:
- 有联系的类型之间可以互相转换
1 |
|
编译成功,int 与 char 直之间有联系。
- 有任何联系的类型之间的转换会被否定
1 |
|
3. reinterpert_cast
reinterpret_cast
:类似于 C 风格的强制类型转换,是 C++ 里的强制类型转换符,不安全。
注意:
- 如果非要进行没有任何联系的类型转换,可以使用
reinterpret_cast
。
实例:
- 如果非要进行没有任何联系的类型转换,可以使用
reinterpret_cast
。
1 |
|
reinterpret_cast
与 C 中类型转换类似,转换成功,不安全。
4. dynamic_cast
dynamic_cast
(动态):运行时期的类型转换,用于继承结构中,可以支持 RTTI 类型识别的上下转换及识别。
将一个基类对象指针(或引用)转换到继承类指针,dynamic_cast
会根据基类指针是否真正指向继承类指针来做相应处理。
1 |
|
执行结果:执行成功。
需求改变了,Derive
实现了一个新功能的 API 接口函数。
1 |
|
void show()
应该区分判断一下,如果 Base* p
指向了其他的派生类对象,调用 p->func()
方法就好。但如果指向 Derive2 对象,不调用 func()
方法,而调用 Derive2 的 derive02 func()
方法。该如何做呢?
这里就要识别 *p
的类型,看它指向哪个对象。此时就需要dynamic_cast()
了。dynamic 会检查 p 指针是否指向的是一个 Derive2 类型的对象;p->vfptr->vftable
RTTI 信息 如果是 dynamic_cast
,转换类型成功,返回 Derive2 对象地址;否则,返回 nullptr。
1 |
|
指向结果:调用成功。