The only me is me, but are you sure the only you is you?
多态指在不同条件下表现出不同的状态,C++中有多种多态的实现方式,本文将对这些方式进行总结。
什么是多态?
多态指为不同数据类型的实体提供统一的接口。
graph TD; A[Polymorphism]-->B["Compile Time"]; A-->C[Run Time]; B-->D["Function Overloading"] B-->E["Operator Overloading"] C-->F["Function Overloading"]
一个不使用多态的例子(静态绑定)
代码如下:
1 | Account a; |
上面的代码在编译器的认知里,p就是一个Account指针,因此他会调用Account的方法(对于传递引用也是如此)。在这种情况下,如果我们需要调用Trust的函数,那么可能需要用if-else语句进行判断,会降低代码的抽象性.因此我们需要一些改进措施。改进如下:
1 | Account a; |
上面的代码几乎和之前的没有区别,唯一的区别是Account中的withdraw被定义为了虚函数,编译器被告知不要在编译时解析函数,而是在运行时进行动态绑定。在运行时会检查p到底指向那个具体的类。
多态的好处
- 更抽象化思考
- 让C++指定运行时调用的函数
不同的多态类型
编译期多态(静态多态)
C++通过函数重载的方式实现编译期多态,编译时编译器会根据参数列表自动匹配最合适的函数。
1 | int Add(int first, int second) |
运算符重载
重载赋值运算符函数
在定义赋值运算符时,我们需要注意以下几点:
- 返回引用类型确保连续赋值,否则如果函数返回值为void,那么无法通过编译(Effective C++条款10)
- 传入参数为const 引用(节省空间,防止修改)
- 在
operator=
中处理自我赋值 - 异常安全性
前两条好解释,第三条看下例:
1 | a[i] = a[j]; //如果i == j 潜在的自我赋值,如果i=j |
自我赋值不安全的代码:
1 | class Bitmap{...}; |
自我赋值安全但异常不安全的代码
1 | Widget& Widget::operator=(const Widget& rhs){ |
一个安全的但是包含分支会导致效率降低的代码
1 | Widget& Widget::operator(const Widget& rhs){ |
重载++运算符
在重载++运算符前,我们先看下标准的前置和后置递增如何实现:
1 | //前置递增 |
运行时多态(基类指针或引用指向派生类对象)
实现方式
- 继承
- 基类指针或引用
- 虚函数
基类指针
基类指针可以指向子类,因为子类是由基类派生出来的,本质上也是基类的一种,我们可以利用这个特性进行动态绑定。
1 | Account *p1 = new Account(); |
虚函数(运行时动态绑定)
虚函数允许我们像处理基类一样处理子类,从而实现运行时多态。在了解虚函数之前,我们需要明确以下几点:
- 重载的函数是静态绑定的(overload)
- 重写的函数是动态绑定的(override)
- 虚函数是一种overridden函数
普通虚函数和纯虚函数
虚函数可以分为普通虚函数和纯虚函数,普通虚函数父类可以实现,子类需要重新实现,而纯虚函数父类只是一个接口,必须依赖子类的实现。只有类的非构造析构非静态成员函数可以作为虚函数。
1 | // 普通虚函数 |
为何要使用纯虚函数?因为在有些情况下,基类本身生成对象是不合理的,比如交通工具可以派生出火车汽车,但是交通工具本身生成对象是不合常理的。
在派生类中重写虚函数
- 函数参数列表和返回值必须和基类完全一致
- virtual关键字非必须,但建议写上
- 如果不提供重写的函数,那么会继承基类函数
1 | class Checking : public Account { |
虚析构函数(防止内存泄漏)
当我们销毁多态对象,可能会发生一些问题:当派生类对象申请的内存空间通过基类指针被销毁时,同时基类中的析构函数为普通析构函数,那么我们的派生类对象可能按照不正确的顺序被销毁,或者有一部分根本就不会被销毁,产生了内存泄漏的问题。我们要确保派生类对象从正确的析构函数开始销毁(先销毁派生部分,再销毁基类部分,基类和子类中的析构函数都会被调用)。解决方法也很简单,一旦一个类有虚函数,那就把析构函数定义为虚函数。
1 | virtual void withdraw(); |
override关键字
函数的重载与重写一个是静态绑定一个是动态绑定,重写要求函数结构和返回值必须严格对应,我们有时候可能将两者混淆,因此C++11中提供了一个override关键字,强制保证重写。参考下面的代码:
1 | class Base{ |
当我们用基类指针方式调用父类和子类的say_hello时,出现的是静态绑定,因此调用的都是基类的say_hello,改进如下:
1 | class Derived : public Base{ |
虚函数表
当我们的类包含虚函数时,会构建一个虚表,同时,如果一个类继承了一个有虚函数的类,他也会有一个虚表。虚表为一个指针数组,指向对应的虚函数。由于虚函数采用了虚表,调用时增加了一次内存开销。
graph LR subgraph A的虚表 node0["0x401ED0"] node1["0x401F10"] end subgraph A的虚函数 node2["A::vfun1"] node3["A::vfun2"] end subgraph A的非虚函数 node4["A::fun1"] node5["A::fun2"] end node0-->node2 node1-->node3
虚表的特点:
- 构建于编译期,虚表的地址是固定不变的,执行期间不能新增或替换。
- 属于类而非对象,意味着一个类只需要一个虚表供所有对象使用,对象有一个虚表指针,指向使用的虚表
*_vptr
动态绑定
动态绑定是通过虚表和虚表指针实现的,例如,假设我们有如下类和及类间继承关系:
1 | class A{ |
其对象模型如下所示:
三个类均有虚函数,因此每个类都含有虚表,每个类的每个对象都有虚表指针,A有两个虚函数,故其有两个虚表,分别指向A::vfunc1()
和A::vfunc2()
,B继承于A,故B可以调用A的函数,但B重写了vfunc1()
,故B的虚表的两个虚表指针分别指向A::vfunc2()
和B::vfunc1()
,而C继承于B,故类C可以调用B的函数,但类C重写了vfunc2()
,故C的两个虚表指针分别指向B::vfunc1()
和C::vfunc2()
。(指向最近的继承的类的虚函数)。
下面,我们定义一个B对象,用A指针指向B
1 | int main() |
由于p是类型为A*的指针,故p只能访问指向基类的部分,但是虚表指针虽然属于基类,但是指向派生类B的虚函数表,故p可以访问B的虚函数,这便是动态绑定的原理。
抽象类
在很多情况下,基类直接生成对象是不合理的,为了解决上述问题,我们引入了纯虚函数(virtual returnType func() = 0;
),纯虚函数必须由子类进行实现,注意是必须实现。
虚函数表的底层实现
在了解了虚函数的工作机制后,我们进一步看一下,虚函数在底层是如何实现的,为了了解其底层,我们有必要探寻C++在产生汇编以及机器码时的工作过程,这里推荐一个网站C++转汇编,如果使用g++也可以实现类似的功能,就是不太方便,目前还没找到比较好的IDE能够实现该功能2。在探寻底层概念时,我们先明确以下几点:
- 在每个包含虚函数的类中,编译器秘密地放置了一个成为vpointer的指针,指向vtable
一段C++代码如下:
1 |
|
转为汇编之后代码如下:
1 | .LC0: # local constant |
从上面的汇编代码中,我们可以看到其生成了类的虚函数表vtable,.quad代表产生一个64位的数值作为地址,不过仅从以上信息,我们并不能知道vtable存放在哪里,为了探究虚函数的位置,我们需要考虑其相对基址的偏移量