👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
目录
一、 多态的概念
多态是面向对象三大基本特征中的最后一个。概念:通俗来说,就是 多种形态,具体点就是 不同的对象去完成某个行为,就会产生出不同的结果。
比如在购买高铁票时,成人原价,学生半价,而军人可以优先购票,对于购票这一相同的动作,需要根据不同的对象提供不同的函数
【代码样例】
【输出结果】
可以看到,不同对象调用同一函数,结果是不同。
二、多态的定义及实现
2.1 多态的构成条件
在继承中要构成多态还有两个条件:
- 调用函数必须是重写的虚函数
- 必须通过父类的指针或者引用调用虚函数
注意:上述两个构成多态的条件缺一不可!
由此可以看出,多态调用看的是指向的对象,指向父类调父类,指向子类调子类;普通对象调用看的是当前对象的类型。
2.2 什么是虚函数
在类的成员函数前加上关键字virtual
称为虚函数。
2.3 虚函数的重写(覆盖)
子类中有一个跟父类完全相同的虚函数。完全相同指的是:返回值类型、函数名字、参数类型完全相同,则称子类的虚函数重写了父类的虚函数。
2.4 多态构成条件的两个例外
- 例外一:子类虚函数可以不使用
virtual
修饰。因为重写的本质是:重写子类虚函数的实现。
虽然在语法上是支持的,但是建议不要省略,因为会破坏代码的可阅读性,可能无法让别人一眼看出多态。
- 例外二:协变(不常用,但是笔试面试会出现)
返回值类型可以不同,但要求返回值必须是父子关系的指针和引用。
【返回类型为各自对象的指针】
【返回对象的引用】
注意:父子类关系的指针/引用,不是必须是自己的,也可以是其他类的,但是要对应匹配子类和父类。还有一点:必须同时是指针,或者同时是引用。
三、 析构函数的重写(面试常考)
问题引入:析构函数加上virtual
是不是虚函数重写?
答案:是。为什么是呢?函数名不相同,就不满足重写的条件啊。其实这里可以理解为编译器对析构函数的函数名做了特殊处理,编译后析构函数的名称都被统一处理成destructor
【输出结果】
接下来就是面试官的连续”攻击”
为什么要这样处理呢?
— 那肯定是要构成重写
为什么要构成重写?好像父类不加virtual
关键字也是可以的
如果不对析构重写的话,那么下面有一个场景是过不了的(记住此场景)
【输出结果】
以上代码缺少了子类的析构函数!!!发生了内存泄漏。说明没有调用子类的析构函数,这是为什么呢?
原因如下:
delete
对于自定义类型的原理是:
- 在空间上执行析构函数,完成对象中资源的清理工作。
- 调用
operator delete
函数释放对象的空间(operator delete
本质就是调用free
函数)。
即对于最后一个delete a
,先调用了析构函数a->destructor()
,然后再调用operator delete
函数释放a
指向的空间。
虽然析构函数名相同(统一处理成destructor
),但是函数并没有用virtual
修饰,因此并不构成重写,只能构成了重定义(隐藏)。所以,对于普通对象的调用,看的是当前调用者的类型。因此上述代码a
的类型为A
,调用的是父类的析构函数。
而我们期望的是指向什么对象,就调用对应对象的函数,因此就需要多态了。所以需要在父类的析构函数中加上virtual
修饰(子类可加可不加)
总结:只要一个类被继承,都要在其父类的析构函数前加上virtual
四、关键字:override 和 final(C++11)
C++11
提供了override
和final
两个关键字,可以帮助用户检测是否重写。
4.1 override
作用:修饰子类的虚函数(写在子类函数括号的后面),检查是否构成重写,若不构成,则报错,反之什么事也不发生
4.2 final
作用:修饰父类的虚函数,不允许子类重写父类的虚函数,即不构成多态
对父类的虚函数加上final
:无法构成重写
注:final
可以修饰子类的虚函数,因为子类也有可能成为父类;但override
无法修饰父类的虚函数,因为父类之上可能没有父类了,自然无法构成重写。
4.3 final修饰父类
final
还可以修饰父类,表示:父类不可被继承。
五、重载、覆盖(重写)、隐藏(重定义)的对比(面试常考)
-
函数重载:同一个作用域中,函数名相同,参数个数不同 or 参数类型顺序不同 or 参数类型不同。
-
重定义:又称隐藏。子类和父类可能会出现同名成员(函数名/变量名相同,都构成重定义,与返回值类型和参数列表无关),若出现这种情况,默认会将父类的同名成员隐藏,进而执行子类的同名成员。若想访问父类的同名成员,可以加域作用访问限定符。
-
重写:又称覆盖。父类有虚函数,并且子类也存在完全相同的虚函数。完全相同指的是:返回值类型相同、函数名相同、参数类型完全相同。
六、抽象类
6.1 概念
- 在虚函数的后面写上
= 0
,则这个函数为纯虚函数,只要包含纯虚函数的类叫做抽象类(也叫接口类)
- 需要注意的是:抽象类不能实例化出对象,派生类继承后也不能实例化出对象。
- 但只有重写纯虚函数,派生类才能实例化出对象。派生类重写不需要在函数后加上
=0
。
抽象类既然不能实例化出对象,那抽象类存在的意义是什么? -> 强制子类去重写纯虚函数。
6.2 接口继承和实现继承
-
【实现继承】 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。
-
【接口继承】 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。
-
【建议】如果不实现多态,就不要把函数定义成虚函数。
七、多态的底层原理
7.1 虚函数表
多态究竟是如何实现的?先来看一段简单的代码,同时也是一道笔试题。
通过分析:类中只有一个虚函数,而对象是不存储函数。因此,大小只算上成员变应该是1
【运行结果】
可以通过【监视窗口】观察
我们发现:c
对象还多了一个名为_vfptr
,从名字上分析:v
代表virtual
,f
代表function
,ptr
代表pointer
,因此对象中的这个指针我们叫做虚函数表指针,也称作虚表指针。
- 那么虚函数表中到底放的是什么?
【监视窗口】
通过观察:子类对象除了有自己的成员变量,还继承了父类的成员变量和虚函数表指针(对象中存的不是虚表,存的是虚表指针,指向虚表)。
实际上虚表当中存储的就是虚函数的地址,而不是虚函数,虚函数和普通函数一样的,都是存在代码段的。虽然子类继承父类的虚函数Func1
,但是子类对父类的虚函数Func1
进行了重写,因此,子类对象的虚表当中存储的是父类重写的虚函数Func1
的地址。
当然了,如果子类没有重写父类的虚函数,那么虚函数表里的虚函数的地址都是相同的
这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
总结一下子类的虚表生成:
- 先将父类中的虚表内容拷贝一份到派生类虚表中。
- 如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数。
- 子类自己新增加的虚函数按子类的声明次序增加到子类虚表的最后
7.2 多态的原理
根据以上分析,就可以解释多态是怎么做到指向父类调父类,指向子类调子类?
- 指向父类,那么就直接在父类对象的虚函数表找到虚函数地址,然后调用即可。
- 指向子类,子类对象通过切片赋值给父类对象,将属于父类继承的那一块切出来,看到的本质上还是父类,然后再通过继承父类的虚函数表中找到虚函数重写的地址。
现在想想多态构成的两个条件:
- 完成虚函数的重写
- 必须使用父类的指针或者引用去调用虚函数。
必须完成虚函数的重写是因为我们需要完成子类虚表当中虚函数地址的覆盖来达到不同对象调用会产生不同的结果。那为什么必须使用父类的指针或者引用去调用虚函数呢?为什么子类的指针或引用去调用虚函数达不到多态的效果呢?
原因是:
- 如果使用子类的指针或者引用调用时,只能去子类对象的虚函数表指针去找虚函数的地址,找不到父类的
- 而使用父类指针或者引用时,实际上是一种切片行为,切片时只会让父类指针或者引用得到父类对象或子类对象中切出来的那一部分。
- 因此如果指向的对象是父类,那么就在父类对象的虚函数表指针去找虚函数的地址。
- 如果指向的对象是子类,由于切片,就会在子类对象中属于父类的那一部分找,而子类继承了父类的虚函数表,如果子类重写了父类的虚函数,那么就能达到不同对象调用会产生不同的结果。
又有一个问题:为什么不通过父类对象去调用虚函数呢?原因是:对象切片和指针/引用切片是由差距的。对象赋值不会拷贝虚表,如果拷贝虚表,那么如果指向对象是父类,调用的就是子类的虚函数,就达不到不同对象调用会产生不同的结果。
7.3 虚表存在哪
总结一下前面的:
- 对象中不存虚表,存的是虚表指针,虚表指针指向虚表
- 而虚表中存储的是虚函数的地址,而不是虚函数
- 而虚函数和普通函数一样都是存储在代码段的
那么问题来了,虚表存在哪?
A. 栈
B. 堆
C. 数据段(静态区)
D. 代码段(常量区)
- 首先排除堆。
因为堆是给用户手动申请和释放的,编译器不可能自己new
或者malloc
。
- 其次栈也不太可能。
栈都是伴随的栈帧走的,假设存在栈帧上,那么同类型的对象在不同栈帧上,就会创建不同的虚表,我们可以来验证一下:
因此,可以得出结论:同类型的对象共用虚表。因此虚表不可能在栈上。
- 那到底存在数据段还是代码段呢?
可以打印地址对比,因为同区域的地址是不会偏离太远的
因此,虚表是存在代码段(常量区)。
八、动态绑定与静态绑定
- 静态绑定:在程序编译时确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定:是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态,比如多态。
【代码】
可以通过查看汇编的方式进一步理解静态绑定和动态绑定:
- 不满足多态的函数调用是编译时确认好的
首先Func1
虽然是虚函数,但是不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call
地址
- 如果符合多态,运行时到指向对象的虚函数表中找到调用函数的地址
相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后比不构成多态的情况多,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。
九、单继承和多继承关系的虚函数表
9.1 单继承中的虚函数表
在前头讲过:子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。观察下图中的监视窗口中我们发现看不见func3
和func4
。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug
。那么我们如何查看d
的虚表呢?下面我们使用代码打印出虚表中的函数
我们可以使用以下代码,打印上述基类和派生类对象的虚表内容,在打印过程中可以顺便用虚函数地址调用对应的虚函数,从而打印出虚函数的函数名,这样可以进一步确定虚表当中存储的是哪一个函数的地址。
运行结果如下:
9.2 多继承中的虚函数表
以下列多继承关系为例,我们来看看基类和派生类的虚表模型。
其中,两个基类的虚表模型如下:
子类的虚表模型如下:
观察上图中的监视窗口中我们发现看不见func3
。同理的,使用代码打印出虚表中的函数来验证:
打印函数还是上面那个不变,下面是主函数部分
在多继承关系当中,派生类的虚表生成过程如下:
- 将继承各个基类的虚表内容拷贝到派生类中。
- 对派生类重写了的虚函数地址进行覆盖(比如
Func1
) - 多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
十、继承和多态常见的面试问题
10.1 概念查考
- 下面程序输出结果是什么? ()
#include <iostream>
using namespace std;
class A
{
public:
A(char* s) { cout << s << endl; }
~A() {};
};
class B : virtual public A
{
public:
B(char* s1, char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class C : virtual public A
{
public:
C(char* s1, char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class D : public B, public C
{
public:
D(char* s1, char* s2, char* s3, char* s4)
:B(s1, s2)
, C(s1, s3)
, A(s1)
{
cout << s4 << endl;
}
};
int main()
{
D* p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
A.class A class B class C class D
B.class D class B class C class A
C.class D class C class B class A
D.class A class C class C class D
- 下面说法正确的是( ) -> 多继承中指针偏移问题
class Base1
{
public:
int _b1;
};
class Base2
{
public:
int _b2;
};
class Derive : public Base1, public Base2
{
public: int _d;
};
int main()
{
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3
- 以下程序输出结果是什么()
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
public:
void func(int val = 0)
{
std::cout << "B->" << val << std::endl;
}
};
int main()
{
B* p = new B;
p->test();
return 0;
}
A.A->0 B.B->1 C.A->1 D.B->0 E.编译错误 F.以上都不正确
10.2 问答题
- 什么是多态?
多态又分为静态多态和动态多态。
- 静态多态:函数重载。
- 动态多态:继承中虚函数重写 + 父类指针调用。 -> 不同对象,去调用同一函数,产生了不同的行为。
可以。我们知道内联函数是会在调用的地方展开的,也就是说内联函数是没有地址的。当我们把内联函数定义虚函数后,编译器就忽略了该函数的内联属性,这个函数就不再是内联函数了,因为需要将虚函数的地址放到虚表中去。
- 静态成员函数可以是虚函数吗?
- 静态成员函数不能是虚函数。因为静态成员函数没有
this
指针,使用类型::成员函数
的调用方式无法访问虚表,所以静态成员函数无法放进虚表。static
和virtual
是不能同时使用的- 静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的
this
指针,可以通过类名::成员函数名 直接调用,此时没有this
无法拿到虚表,就无法实现多态,因此不能设置为虚函数
- 友元函数可以作为虚函数吗?
友元函数不属于成员函数,不能成为虚函数.
- 构造函数可以是虚函数吗?
构造函数不能是虚函数,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
- 对象访问普通函数快还是虚函数更快?
对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,那直接访问就行了,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。
- 虚函数表是在什么阶段生成的?存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。点击跳转
- C++菱形继承的问题?虚继承的原理?
- 菱形继承的问题:菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
- 虚继承的原理点击跳转:虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。
- 什么是抽象类?抽象类的作用? 点击跳转