C++中的继承与多态
继承
基本概念
C++中的类可以通过继承别的类来定义,这能够大幅提高代码的复用效率。继承的语法为
1 | |
根据访问修饰符,类的继承分为
- 公有继承 不改变基类中成员的访问属性
- 保护继承 将基类的
public和protected成员都变成protected - 私有继承 将基类的
public和protected成员都变成private
构造函数和析构函数的调用顺序
- 构造函数的调用顺序:自上而下
当建立一个对象时,首先调用基类的构造函数,然后调用下一个派生类的构造函数,依次类推,直至到达最底层目标派生类的构造函数 - 析构函数的调用顺序:自下而上
当删除一个对象时,首先调用该派生类的析构函数,然后调用上一层基类的析构函数,依次类推,直到到达最顶层的基类析构函数
多继承
C++中一个子类可以继承多个父类。比如一个男子篮球运动员,那就可以定义两个父类,一个MalePeople类,一个BasketballPlayer类,那如果想要定义男子篮球运动员类,可以定义一个MaleBasketballPlayer类,继承MalePeople和BaskeballPlayer,代码如下:
1 | |
虚继承
graph TB
A1["A"]
A2["A"]
%% 从它们向下连线
A1 --> B["B"]
A2 --> C["C"]
%% 最后合并到 D
B --> D["D"]
C --> D
当一个类使用普通的继承来继承两个父类,而这两个父类存在一样的父类,如上图所示,那么D中就会存储两份A的数据,造成内存的浪费。此外,还会导致二义性,当D调用A的方法时,由于有两个A,编译器也无法判断调用哪个方法。为了解决这个问题,可以采用虚继承。
graph TB
A --> B["B"]
A --> C["C"]
%% 最后合并到 D
B --> D["D"]
C --> D
使用虚继承时,只有一个共享的基类被继承了。
使用虚继承只需要在继承时添加virtual进行修饰即可,注意是在B和C继承A时用virtual进行修饰而不是D继承B和C时。
1 | |
多态
多态是指同一个接口,在不同的对象调用时,根据对象的类型来执行不同的内容,产生不同的行为。例如男人和女人都能调用说话这一接口,但是他们说话的音色语调等特性都不相同。
虚函数与多态
1 | |
上面的代码中Speak()用virtual进行修饰,表示这是一个虚函数,虚函数可以被派生类所覆盖。
1 | |
MalePeople和FemalePeople继承了People类并将虚函数覆盖
1 | |
p1,p2和p3都是People类的指针,调用的也都是相同的函数Speak(),但是由于指向的具体实例不同,产生了不同的行为,这就是多态。
多态同样可以通过基类的引用来实现。
需要注意的是
- 虚函数的重写要求函数名、函数参数与返回值都相同
- 重写只对虚函数的函数体有效,返回值类型、函数名、参数列表和缺省参数都不能修改
- 多态必须要满足两个条件
- 虚函数的重写
- 用父类类型的指针或引用(接收父类对象或子类对象)的对象去调用虚函数
多态的原理
多态的关键在于通过基类指针或引用调用一个虚函数时,编译时不能确定到底调用的是基类还是派生类的函数,运行时才能确定。
1. 虚函数表指针(vptr)
1 | |
在64位的设备上运行上面的代码,结果是
1 | |
说明包含虚函数的类会多出8个字节的大小,实际上这8个字节存放的就是指向虚函数表的指针,也就是虚函数表的地址。
2. 虚函数表(vtable)
每一个包含虚函数的类,都有一个虚函数表(vtable),虚函数表会列出这个类中虚函数的地址。这个类的所有实例中都存放着指向这个虚函数表的指针。
虚函数表本质上一是一个虚函数指针数组,元素的顺序取决于虚函数声明的顺序。它有以下性质
- 虚函数表在编译期间生成,构造对象时初始化的是虚函数表指针
- 虚函数继承体系中,基类先生成一份虚表,之后派生类自己的虚表都是基于从父类继承下来的虚表
- 如果派生类重写了基类中某个虚函数,会在虚函数表内用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址,如果子类没有重写,则虚函数表和父类的虚函数表的元素完全一样
- 派生类自己新增加的虚函数,从继承的虚表的最后一个元素开始,按其在派生类中的声明次序增加到派生类虚表的最后。
- 虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中
- 同一类的对象共享一个虚函数表
3. 多态的实现机制
1 | |
在编译时,如果Speak()不是虚函数,那么编译器会直接将其绑定到固定的函数地址,但由于Speak()被声明为virtual,那么编译器会将基类指针对其的调用编译为三步
- 通过基类指针读取虚函数表指针,如果此时基类指针指向的是派生类,那么读取的就是指向派生类虚函数表的指针
- 根据
Speak()声明的顺序(也就是其在虚函数表中的偏移),用虚函数表指针读取它真正的函数地址 - 将第二步得到的函数地址与函数名绑定
也就是说,通过虚函数实现多态时,函数的地址是在运行时才被绑定的,因此这种多态的实现也叫做运行时多态和动态多态
纯虚函数与抽象类
纯虚函数是在虚函数的基础上用=0修饰
1 | |
含有纯虚函数的类叫做抽象类,抽象类不允许被实例化,只能通过派生来来实例化。
虚析构函数
在多态的情景下,如果析构函数不是虚函数,那么通过基类的指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,那么可能会导致派生类的资源没有释放,造成内存泄漏。
因此在实现多态时,需要将基类的析构函数设置为虚函数。
需要注意的是,构造函数不能声明为虚函数,因为虚函数机制依赖于对象的 vtable 指针,而 vtable 指针是在基类构造完成之后才会被设置的。在调用构造函数的过程中,对象还未完全构建,虚表还未就绪,因此无法调用“动态绑定”的虚函数。
final关键字
final是C++11中引入的关键字,用于修饰类和类中的虚函数。
- 用
final修饰类时,禁止该类被继承,继承该类会导致编译错误。1
2
3
4
5class A final
{};
class B : public A //编译错误
{}; final关键字还可以标记虚函数,从而禁止子类中重写该函数1
2
3
4
5
6
7
8
9class A {
public:
virtual void func() final {}
};
class B : public A {
public:
void func() {} //编译错误
};
override关键字
如果某些情况因为疏忽而导致函数没有进行重写,这种情况在编译期间是不会报错的,只有程序运行时没有得到预期结果才可能意识到出现了问题,等到这时再debug已经得不偿失了。
因此C++11引入了override关键字,如果被override关键字修饰的派生类虚函数没有重写,则无法通过编译。
1 | |