文章目录
- 1. 什么是多态
- 1.1 静态多态和动态多态
- 2. 动态多态
- 2.1 动态多态的实现
- 2.2 虚函数
- 2.2.1 虚函数的重写
- 2.2.2 虚函数协变
- 2.2.3 析构函数的重写
- 2.2.4 override和final
- 3. 动态多态原理解析
- 3.1 _vfptr
- 3.2 动态绑定与静态绑定
- 3.3 虚函数表详解
- 4. 纯虚函数和抽象类
- 5. 重载、重写、重定义
- 6. 易错知识点归纳
1. 什么是多态
多态,从字面意义上说,就是多种形态,即一个东西具有多种不同的形态。
举个例子,动物都会叫,但是具体到不同的动物,叫声是不一样的,狗的叫声是"汪汪",猫的叫声是"喵喵",这实际上就是多态的一种体现。
1.1 静态多态和动态多态
静态多态, 也称编译时多态,它是在编译的过程中实现的。典型的例子如函数重载和函数模板,不同的参数,会对应不同形态的函数,而具体参数的匹配,是在编译阶段完成的,即在编译阶段决定到底采用哪个形态。
动态多态, 也称运行时多态,它是在程序运行的过程中实现的。下面将重点讲解动态多态。
2. 动态多态
2.1 动态多态的实现
我们通过虚函数来实现动态多态。先看下面的这个例子:
上述代码的输出结果是什么呢?你可能会认为类型为A*的ptr1,调用的应该是A类中test函数,所以应该输出hello China,但实际上,输出结果是hello world。
为什么会这样呢?这就是动态多态的典型应用。
通过观察上述案例,我们发现实现动态多态的几个关键点:
- 首先,需要有一个基类的指针或引用,但具体指向或引用的是派生类对象。
- 其次,要实现为多态的,基类中的函数,需要被virtual修饰,实现为虚函数。子类中的相应函数,可以用virtual修饰,也可以不用,但要确保子类中的函数与父类中的函数,在返回类型,函数名,参数个数和类型上完全相同,这样就能满足虚函数的重写条件,进而实现多态。
2.2 虚函数
2.2.1 虚函数的重写
虚函数,主要针对的是类的成员函数,非成员函数不能实现为虚函数。
虚函数,在成员函数前加上一个virtual关键字。
虚函数是用来实现多态的,而实现多态会引入虚表和虚表指针(这个后面会讲),会带来额外的消耗,因此不需实现为多态时,不要乱用虚函数。
虚函数实现多态,最为重要的一点就是虚函数的重写或者说虚函数的覆盖。
当派生类中的某个函数,与基类中某个虚函数,在返回类型,函数名和参数列表都完全相同时,便构成虚函数的重写。此时,派生类中的这个函数是否添加virtual,均构成多态。
2.2.2 虚函数协变
在虚函数重写中,明确要求有三同(返回类型,函数名,参数列表),这样才能重写虚函数,才能构成多态。
但是虚函数的协变允许返回类型不同。 构成虚函数协变时,基类中的虚函数需要返回某个基类对象的指针或引用;派生类中的虚函数需要返回与之对应的某个派生类对象的指针或引用。
我们可以看到,这样的情况,在返回类型上有一个照应,因此称为虚函数的协变。
上述代码中,应用了虚函数的协变,因此依然构成多态。
2.2.3 析构函数的重写
看如下代码:
在上述代码中,基类A的析构与基类B的析构,构成虚函数的重写吗?
答案是构成。因为,编译器实质上会对析构函数的名称做特殊处理,统一处理成destructor,因此满足虚函数重写的条件。在继承中,这样将析构函数的名称处理相同,也会导致派生类中的析构对基类中的析构造成隐藏,要想在派生类中显式调用基类的析构,还需指定类域。
明白了这点后,我们来思考一个问题:为什么基类中的析构,建议设计为虚函数?
如果,基类中的析构不设计为虚函数,那么在上图中,两次使用delete释放资源时,两次都会调用的A类的析构,导致B类对象中的资源可能未完全释放,因而造成内存泄漏。
2.2.4 override和final
在派生类中,虚函数的重写可能因为派生类中的函数声明未完全相同而失败,但这并不构成语法错误,只是不再是多态。如果,我们想要知道虚函数是否重写成功,可以使用override关键字。
同时,如果我们不想让派生类重写基类中的某个虚函数,我们可以使用 final 关键字(PS:final关键字还用于实现一个无法被继承的类)
3. 动态多态原理解析
在讲解原理之前,我们先来看如下代码:
考虑到内存对齐,你可能会认为最终打印出的结果会是8,但是在Visual Studio 32位的环境下,结果是12;在64位的环境下,结果是16。
为什么会是这样呢?我们调试起来,打开监视窗口观察,发现这个类中,竟然多出一个成员。
这个_vfptr
究竟是何方神圣?
实质上,这个成员变量,正是动态多态实现的关键。
3.1 _vfptr
这个变量,我们把它称作虚表指针,全称应该是虚函数表指针。这个指针,指向的是一个虚函数表,这个表实质上就是一个存储虚函数地址的数组。
在编译的过程中,编译器会将函数的具体内容转换成一条条指令存储起来,函数的调用,本质上就是先call,跳转至jump的地址,然后jump再跳转到要调用的函数的地址处,实质上就是函数第一条指令的地址,然后函数进行执行。
对于虚函数而言,也会在编译阶段生成相应的地址,而这些地址就存储在虚函数表中,通过虚表指针,我们就能够拿到这张虚表。
可以看到,a和b对象各自的虚表中,存储了相应的虚函数的地址。
现在,我们来梳理整个过程。
- 首先,在基类中,某个函数实现为虚函数,那么在编译过程中,这个基类的虚表就会存储这个虚函数的地址
- 其次,在派生类继承基类后,也会将这个虚表指针继承下来,但由于派生类中虚函数的重写,所以虚表指针所指向的虚表中的内容会被更改,变为派生类中重写的虚函数的地址。
- 在这种情况下,一个基类的指针或引用,再调用构成多态的虚函数时,需要到其所指向对象的虚表中找到对应函数的地址,而后再调用。这样就确保了,指向的若是基类对象,便到基类对象的虚表中找相应函数的地址;指向的若是派生类对象,便到派生类对象的虚表中找相应函数的地址。
上述过程,便是多态的实现原理。
3.2 动态绑定与静态绑定
理解了虚函数的调用机制,便能够很好地区分动态绑定与静态绑定。
普通函数就是静态绑定。也就是说,对于普通函数的调用,其地址在编译时就已经确定了,程序运行时直接跳转到相应地址处执行即可。一般而言,这些普通函数的指令存储在代码段,即常量区。
虚函数就是动态绑定。在编译的时候,虚函数也会生成相应的地址,但是具体调用的是哪个地址是不确定的,一定要在程序运行起来后,通过基类的指针或引用,到实际指向的对象的虚表中,去查找到相应的地址,然后跳转执行。也就是说,要在运行的时候才能确定相应地址,所以叫动态绑定。
3.3 虚函数表详解
前面讲过虚函数表指针指向虚函数表,虚函数表中存放虚函数的地址,下面来具体讲一讲虚函数表。
各个类的对象(包含虚函数),都有各自的虚表指针,但是同类对象共用一张虚表,不同类对象的虚表不同。
a 与 _a 的虚表相同,b 与 _b的虚表相同,但是不同类的虚表不同。
基类的虚表中包含的基类虚函数的地址,但派生类在继承的过程中,会发生虚函数的重写,即派生类在继承基类的虚表指针后,不会再重新生成一个虚表指针,而是将这个虚表指针所指向的虚表改变。因此,对于派生类而言,虚表中实际存放的地址应包括:基类中未被重写的虚函数的地址、派生类重写基类的虚函数的地址、派生类中又独立定义的虚函数的地址。
所以,虚函数表实际上是一个函数指针数组,在某些的编译器下,会在虚函数表的结束处,会放置一个0x00000000(32位) 用作结束标识,但这个是标准未定义的,VS中会放,但g++中则不会放。
同时,对于虚函数表具体存放在哪里的问题,这也是标准未定义的。虚函数在编译后,肯定是存放在代码段,但虚函数表不确定,在vs中,虚函数表是存放在代码段的。
4. 纯虚函数和抽象类
在虚函数后加上 =0 即构成纯虚函数。
纯虚函数声明即可,是不需要实际函数体的,这不是说C++规定纯虚函数不能有函数体,而是纯虚函数注定是要被重写的,因此函数体有没有无所谓。
包含纯虚函数的类,被称作抽象类;继承了抽象类的派生类,如果没有完成对所有纯虚函数的重写的话,依然是抽象类。
抽象类是没有办法实例化出对象的,这也就强制派生类必须对基类中的纯虚函数进行重写,这样派生类才能实例化处对象。
就博主个人的理解而言,本来多态是可以发生在基类和派生类,派生类和派生类之间的,而基类中纯虚函数的引入,实质上就将多态限制在了派生类对象之间。
虽然,抽象类不能实例化出对象,但是抽象类可以作为指针所指向的对象或引用类型,专门用于实现派生类对象间的多态。
5. 重载、重写、重定义
注意区分这三个易混淆的概念。
重载
,是指函数的重载,要求函数名相同,但具有不同的参数列表,是静态的多态。
重写
,是指虚函数的重写(注意,重写一般是重写基类中的函数内容,但函数声明是不会重写的,这也是为什么要三同才能构成虚函数重写,至于协变是例外),是动态多态。
重定义
,这个重定义,和我们以往所讲的标识符的重定义有点区别,它是特指继承体系中的重定义,即覆盖。也就是说,当子类和父类中出现名称相同的成员变量或函数时,就构成重定义或覆盖。此时,直接访问的是子类中的成员,若想访问父类中的同名成员,需要指定类域。
6. 易错知识点归纳
- 只有类的成员函数方能设置为虚函数,非成员函数不能为虚函数。
- 静态成员函数不能设置为虚函数。因为静态成员函数为整个类所共有,可以指定类域进行访问,不需要this指针,相应地,也就无法拿到虚表,无法实现多态,因此static和virtual这两个关键字不能同时存在。
- 调用派生类的虚函数不受派生类的访问限定符限制,而受基类相应的被重写的虚函数的访问限定符的限制。这是因为,实际上调用的还是基类的相应虚函数,只不过由于多态,其动态绑定到派生类的重写的虚函数上,或者说由于派生类虚函数的重写,调用的仍是基类中的虚函数,但实际执行的函数体,是派生类中相应虚函数的函数体。
- 由于虚函数指针是对象中的实际成员变量,而多态的实现要借用虚函数指针,因此在对象的构建过程中,多态机制是失效的,最起码要到对象构建的初始化列表走完时,多态机制方能生效。