C++中多态相关知识
1.多态的概念
1.1概念
- 多态的概念:通俗来说,就是
多种形态
,具体点就是去完成某个行为
,当不同的对象去完成时会产生出不同的状态
- 举个例子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票
2.多态的定义及实现
2.1多态构成的条件
- 多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价
- 在继承中要构成多态还有两个条件:
Ⅰ
.派生类中,必须对基类的虚函数进行重写
,被调用的函数必须是虚函数Ⅱ
.必须通过基类的指针
或者引用
去调用虚函数
2.2虚函数
- 虚函数:即被virtual修饰的类成员函数称为虚函数
2.3虚函数的重写
- 虚函数的重写:派生类中有一个
跟基类完全相同的虚函数
(即派生类虚函数与基类虚函数的返回值类型
、函数名字
、参数列表完全相同
),称子类的虚函数
重写了基类的虚函数
- 这里有一个注意的点:在重写基类的虚函数时,派生类的虚函数不加
virtual
关键字,也可以构成重写
,因为基类的虚函数被继承下来依然保持着虚函数的属性
,但是这样的写法不太规范,并不建议使用
2.3.1虚函数重写的两个例外
Ⅰ
.协变:(基类与派生类虚函数返回值类型不同)- 派生类重写基类虚函数时,与基类虚函数返回值类型不同。即
基类虚函数返回基类对象的指针或者引用
,派生类虚函数返回派生类对象的指针或者引用
时,称为协变
Ⅱ
.析构函数的重写:(基类与派生类析构函数的名字不同)- 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,即使基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称
统一处理成destructor
2.4 C++11 overrride 和 final
- 从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:
C++11
提供了override
和final
两个关键字,可以帮助用户检测是否重写
- final:修饰虚函数,表示该虚函数不能再被继承
- override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
2.5重载、覆盖(重写)、隐藏(重定义)的对比
3.抽象类
3.1概念
- 在虚函数的后面加上
=0
,则这个函数为纯虚函数
。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象
。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
3.2接口继承和实现继承
- 普通函数的继承是一种
实现继承
,派生类继承了基类函数,可以使用函数,继承的是函数的实现 - 虚函数的继承是一种
接口继承
,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口 - 所以如果不实现多态,不要把函数定义成虚函数
4.多态的的原理
4.1虚函数表
-
我们先看一道常见的笔试题:求sizeof(class)的大小
-
普通函数
-
虚函数
-
测试完我们发现是8bytes,我们打开监视窗口会发现
-
我们再增加一个派生类去探一探这虚函数表
-
我们打开监视窗口看一看
-
总结:
-
Ⅰ
.派生类的虚表生成过程:a
.先将基类的虚表内容拷贝一份到派生类虚表中b
.如果派生类重写了基类中的某个虚函数,用派生类自己的虚函数覆盖基类的虚函数c
.派生类新增加的虚函数按其再派生类中的声明顺序依次加到虚表的后面 -
Ⅱ
.虚函数表的本质其实是一个指针数组,数组以nullptr
结束,a.
数组中每一个元素为虚函数指针,b.
子类会继承父类的虚函数表,c
.普通函数指针不会放在虚表中,d
.虚函数指针在虚表的存放顺序和声明/定义的顺序一致 -
Ⅲ
.虚表指针存在对象中,虚表存在代码段,虚函数指针存在虚表中,虚函数再代码段, -
Ⅳ
.虚函数指针指向虚函数的首地址,它是一个二级指针,因为指向的虚表是指针数组
4.2多态实现的原理
- 我们还是拿买票来举例子:
- 打开监视窗口,我们一探究竟
- 此时我们转到汇编,去看看函数调用的过程
- 总结:
- 多态调用的过程(通过指针/引用调用虚函数)
- a.首先通过实际指向的实体获取虚表指针
- b.通过虚表指针找到虚表
- c.从虚表中找到执行函数的实际地址
- d.执行对应地址的函数指令,完成多态行为
4.3动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:
函数重载
,模板编程
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态,比如:
继承
5.单继承和多继承关系中的虚函数表
5.1单继承中的虚函数表
- 我们举一个单继承的栗子:
- 那么我可以写一段代码来查看d的虚表:
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
int main()
{
Base b;
Derive d;
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
return 0;
}
-
运行结果:
-
虚表中的位置:
- 单继承虚表总结:
Ⅰ
.子类继承父类的虚表Ⅱ
.子类重写的虚函数,其虚函数指针会覆盖父类中的虚函数指针Ⅲ
.子类型定义的虚函数,其虚函数指针会按照定义/声明的顺序存放在继承的父类的虚表末尾
5.2多继承中的虚函数表
-
举一个多继承的栗子:
-
我们打开监视窗口:
-
我们依然写一段代码来查看虚表地址:
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));
PrintVTable(vTableb2);
return 0;
}
-
运行结果:
-
多继承虚表总结:
-
Ⅰ
.虚表的个数与直接父类的个数相同 -
Ⅱ
.子类继承每一个直接的父类 -
Ⅲ
.子类重写的虚函数,其虚函数指针会覆盖对应父类中的虚函数指针 -
Ⅳ
.子类新定义的虚函数,其虚函数指针会按照声明或定义的顺序依次存放在继承的第一个直接父类的虚表末尾