前言
需要声明的,本节课件中的代码及解释都是在vs2022下的x86程序中,涉及的指针都是4bytes。 如果要其他平台下,部分代码需要改动。
比如:如果是x64程序,则需要考虑指针是8bytes问题等等
1. 多态的概念
1.1 概念
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
举个栗子:
比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
再举个栗子:
最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包支付给奖励金的活动。
那么大家想想为什么有人扫的红包又大又新鲜8块、10块...
而有人扫的红包都是1毛,5 毛....
其实这背后也是一个多态行为。
支付宝首先会分析你的账户数据,比如你是新用户、比如 你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;
比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;
总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。
2. 多态的定义及实现
2.1多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
比如Student继承了 Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
少一个都不构成多态!!!
但是有两点是例外:
1.除父类外,其他子类中的函数不必使用 virtual 修饰,此时仍然能构成多态(注意三同,需要构成重写)
2.父子类中的虚函数返回值可以不相同,但此时需要返回对应的父类指针或子类指针,确保构成多态,这一现象称为协变
如何快速判断是否构成多态?
首先观察父类的函数中是否出现了 virtual 关键字
其次观察是否出现虚函数重写现象。是否满足三同 。 三同:返回值、函数名、参数(协变例外)
最后再看调用虚函数时,是否为【父类指针】或【父类引用】
父类指针或引用调用函数时,如何判断函数调用关系?
若满足多态:看其指向对象的类型,调用这个类型的成员函数
不满足多态:看具体调用者的类型,进行对应的成员函数调用
2.2 虚函数
虚函数:即被virtual修饰的类成员函数称为虚函数。
2.3虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
虚函数的作用是在目标函数(想要构成多态的函数)之间构成重写(覆盖),一旦构成了重写(覆盖),那么子类对象在实现此虚函数时,会继承父类中的虚函数接口(返回值、函数名、参数列表)
然后覆盖至子类对应的虚函数处,因此重写又叫做覆盖
所以虚函数就是虚拟的函数,可以被覆盖的、实际形态未确定的函数,使用 virtual 修饰后,就是在告诉编译器:标记此函数,调用时要触发覆盖行为,同时虚表指针需要找到正确的函数进行调用
那析构函数加virtual,是不是虚函数重写?
是,因为类析构函数都被处理成destructor这个统一的名字
通过上图,可以发现构成虚函数重写的析构函数,和普通的构都一样,在继承中都是先析构子类,然后析构子类中的父类成员最后再析构父类,所以为什么会费劲c++祖师爷为什么费心思地想让析构函数也能实现虚函数重写呢
原因如下:
会发现这里没销毁子类Student,会造成内存泄漏
造成内存泄漏的原因是:
在创建对象时,系统会自动调用构造函数进行初始化,这样需要申请内存,同样在程序结束时,或者需要销毁对象然后调用析构函数
但是这边创建对象的类型Person*,普通调用看的是当前对象的类型
p=new Student的时候,new Student的时候调用了Student和Person的默认构造(因为Student是继承了父类)
delete的时候,析构的是当前对象p的类型,p的类型是Person*,所以只销毁了子类中父类的对象
并没有销毁子类Student,造成内存泄漏
所以这才是在析构函数引入了虚函数,并且在底层同一命名为destructor这个统一的名字就是为了构成虚函数重写然后实现多态
虚函数的重写需要注意:
1.除了类中的成员函数外,普通函数不能添加 virtual 关键字进行修饰,因为虚函数、虚函数表、虚表指针是一体的,普通函数没有
2.此处的 virtual 修饰函数为虚函数,与 virtual 修饰类继承为虚继承没有关系:一个是实现多态的基础,而另一个是解决萎形继承的问题
3.同样的,假设不是父类指针或引用进行调用,不会构成多态,也不会发生重写(覆盖)行为
多态的原理就是基于虚函数的重写实现的------如果读者依旧不清楚虚函数的重写可以着重看看
本文章的第四点4.1----虚函数表
2.4C++11 override 和 final
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数 名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失
因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。
1. final:修饰虚函数,表示该虚函数不能再被重写
2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
设计不想被继承类,如何设计?
方法1:基类构造函数私有
在父类public成员前面加CreateOb,可以让子类无法访问父类的public成员
但是创建对象的时候要先调用默认构造才能调用函数
所以在成员函数前面加static就可以了
静态成员函数属于类不属于对象,没有实例化也能调用
方法2:基类加个final
2.5 重载、覆盖(重写)、隐藏(重定义)的对比
截至目前为止,我们已经学习了三个“重”相关函数知识:重载、重写、重定义
这三兄弟不止名字很像,而是功能也都差不多,很多面试题中也喜欢考这三者的区别
重载:即函数重载,函数参数不同而触发,不同的函数参数最终修饰结果不同,确保链接时不会出错,构成重载
重写(覆盖):发生在类中,当出现虚函数且符合重写的三同原则时,则会发生重写(覆盖)行为,具体表现为父类虚函数接口+子类虚函数体,是实现多态的基础
重定义(隐藏):发生在类中,当子类中的函数名与父类中的函数名起冲突时,会隐藏父类同名函数,默认调用子类的函数,可以通过::指定调用
重写和重定义比较容易记混,简言之先看看是否为虚函数,如果是虚函数且三同,则为重写;若不是虚函数且函数名相同,则为重定义
注:在类的继承中,仅仅是函数名相同(未构成重写的情况下),就能触发重定义(隐藏)
3. 抽象类
3.1 概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类)
抽象类不能实例化出对象,派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
注意:只要类中有一个函数被修饰为纯虚函数,那么这个类就会变成抽象类
3.2、抽象类的用途
抽象类适合用于描述无法拥有实体的类,比如 人、动物、植物,毕竟这些都是不能直接使用的,需要经过继承赋予特殊属性后,才能作为一个独立存在的个体(对象)
再强调一次基类用了纯虚函数,派生类必须将这个虚函数重写
不然派生类是无法创建对象的!!!!!!
抽象类的继承很好的体现了函数重写时,继承的是父类虚函数接口的事实,这正是实现多态的基础
普通继承:子类可以直接使用父类中的函数
接口继承:子类虚函数继承父类虚函数的接口,进行重写,构成多态
建议:假如不是为了多态,那么最好不要使用 virtual 修饰函数,更不要尝试定义纯虚函数
4.多态的原理
4.1虚函数表
这是一个空类,其中什么成员都没有,但有两个虚函数
所以一个对象的大小为多少?
答案是4,当前是32位平台下,如果是在64位平台,大小会变为8
大小随平台而变的只能是指针了,因此可以推测当前类中藏着一个虚表指针就是依靠这个虚表指针+虚表实现了多态
我们再用代码进行进一步的观察
通过观察测试我们子类对象中除了_b成员,还多一个__vfptr放在对象的前面(注意有些 平台可能会放到对象的最后面,这个跟平台有关)
对象中的这个指针我们叫做虚函数表指针(v代 表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
那么这个虚函数表的作用是什么呢?
我们往下分析
在继承中要构成多态有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
可以发现虚函数表的核心作用是在运行时实现多态。
当基类的指针或引用指向派生类的对象时,通过虚函数表指针能够调用虚函数表,从而调用派生类中重写的虚函数,而不是基类的版本。这使得不同的对象可以根据它们的实际类型执行不同的操作。
那如果发生虚函数的重写,基类和派生类的虚函数表中会发生什么变化?
首先来捋一捋虚函数表中存放着什么
--------存放着虚函数的地址指针
虚函数表的主要内容是该类中所有虚函数的地址指针。每个虚函数表中的条目对应类中声明的一个虚函数,并指向该虚函数的实现地址。表中条目的顺序与虚函数在类中声明的顺序相对应。
继承结构中的虚函数覆盖
在继承体系中,当派生类覆盖了基类中的虚函数时,派生类的虚函数表中的相应条目会指向派生类中的实现。
如果一个基类声明并定义了虚函数,而派生类没有重写这个虚函数,派生类中的虚函数表中对应该函数的条目将指向基类版本的实现。
如果派生类重写了基类的虚函数,虚函数表中的相应条目将被更新为指向派生类的实现。
每个类(包括基类和派生类)都有自己独立的虚函数表。
对于没有重写的虚函数,派生类的虚函数表条目仍然指向基类中的实现。
对于新添加的虚函数,派生类会在它的虚函数表中增加新的条目。
文字太枯燥了,我们结合代码具体分析
所以虚函数的重写本质上就是覆盖
对于监视窗口观察虚函数表遇到的问题
我们都知道派生类如果新添加的虚函数,派生类会在它的虚函数表中增加新的条目。
但是我们通过监视窗口是看不到的!!!
只能通过内存窗口观察到,因为虚函数表的末尾会以空来结尾(注意是vs的环境下,Linux是没有这个特点的)
但是新增的函数真的会在虚函数表里面吗,这个其实是可以证明的
除了用内存监视窗口证明
还有一种方法
虚函数表本质上就是函数指针数组,通过这个特性我们还可以通过调用虚函数表中的每一个函数来证明新增的函数是否在虚函数表中
虚函数表的位置存储在哪里?
这里还有一个人很容易混淆的问题:
虚函数存在哪的?虚表存在哪的?
答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的!!但是人都是这样深以为然的。
注意:
虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。
另外对象中存的不是虚表,存的是虚表指针。
那么虚表存在哪的呢?
实际我们去验证一下会发现vs下是存在代码段的,
%p特别用于指针类型的数据,输出的是指针所指向的内存地址。
因为虚表 指针就存放在对象开始的前四个字节,所以将对象强转为int*可以使得指针访问权限缩短为四个字节,正好是虚函数表指针的地址
于是就可以输出虚函数表指针的内容,也就是虚函数的表的位置
4.2通过基类的指针或者引用调用虚函数
为什么不能用通过基类的对象调用虚函数?
Person类所创建的所有对象都有虚表指针,而且指针指向的都是同一个虚表
当Student st传参给函数Func()------也就是Func(st)
st赋值给p的过程不会产生临时变量,st会直接切片赋值,会将st中父类的成员变量都赋值给Person p,但是此时Person p中的虚表还是父类的,并不是st中的父类的虚表
此时无论形参传的是父类或者子类,调用的都是父类中的虚表,所以就实现不了多态
有人说为什么不直接拷贝st中父类的虚表到p中?
这样就有可能使得父类对象调用子类的虚函数,如果父类拷贝子类的虚函数表,那么父类对象虚表中是父类虚函数还是子类就不确定了。
为什么要通过基类的指针或者引用调用虚函数?
函数的参数列表是基类的指针或者引用的时候
我们传递子类的时候-----Func(&st)
Student*会强制转换成Person*然后赋予给p
强转类型然后赋予给p的过程中会改变p对虚表访问的位置
没有被强转类型前,p指向的是Person类型对象的虚表还有Person类的成员函数
强转类型然后赋予给p之后,p指向的是Student类型对象的虚表指针还有Person类的成员函数
不过通过这个监视窗口仍然可以发现p的指向内容不单单是student类型对象的虚表指针还有Person类的成员函数
还有Person类的成员变量_a和Student类的对象,但是其实指针都是访问不了的,只是方便监视而已
4.3 动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
5.多继承关系的虚函数表
因为前面说的都是单继承关系的虚函数表,下面我们说说多继承的
此时出现了两个问题:
1.子类 Derive 中新增的虚函数 func3 位于哪张虚表中?
2.为什么重写的同一个 func1 函数,在两张虚表中的地址不相同?
这两个问题是多继承多态中的主要问题
在单继承中,子类中新增的虚函数会放到子类的虚表中,但这里是多继承,子类有两张虚表,所以按照常理来说,应该在两张虚表中都新增虚函数才对
但实际情况是 子类中新增的虚函数默认添加至第一张虚表中
通过 PrintVFTable 函数打印虚表进行验证
第一个问题解决了,现在看看第二个问题:
为什么重写的同一个 func1 函数,在两张虚表中的地址不相同?
这个就要从汇编底层的逻辑出发去观察代码了
先来看看ptr1如何调用函数的:
再看看ptr2如何调用函数的
我们可以发现ptr1调用func1的时候直接是从虚表中找到func1函数的位置,然后跳转到func1函数
而ptr2调用func1的时候会先跳转到别的位置执行指令,将自身的地址减8个字节,然后再跳转到func1函数的位置
为什么ptr2要进行跳转呢?
Dervie d这个对象里面有两个虚函数指针,一个是Base1,一个是Base2。
Base2* ptr2调用func1这个重写的虚函数的时候,它本身指向的内容是错误的。
ptr2指向的是第二张虚表的内容,而func1这个函数的函数地址是存储在第一张虚表中的,所以会让ptr2先跳转到其他地址。
那个地址有个命令,会让跳转完之后ptr2,减去八个字节。此时ptr2就指向第一张虚表的内容。就可以调用func1这个重写的虚函数了
6.面试题
这个是大厂的面试题,可以说很多码农都稍不小心就答错了
每个派生类对象都是一个基类对象。
这个成员函数是写在代码段的,然后子类继承这个函数而且并没有重写这个函数,那么现在代码段的这个成员函数将由子类和父类共同拥有,是指内存上共享同一段内存的意思
父类的成员函数,成员函数里面的this成员都是父类类型的对象
B* p去调用text(),使得p的类型B*强转成A*,然后将p赋予给成员函数里的对象this,使得this此时指向子类中父类的虚表指针
text中的this成员去调用func()
多态条件:
1.三同(函数名、返回类型、参数列表)【func()满足】
2.基类指针或者引用去调用【func()由this成员调用,this成员此时类型是A*,func()也满足】
所以func()满足多态
然后此时调用的B类的func()函数,因为this成员指向的是子类中父类的虚表指针
最重要的一点:虚函数重写,函数名返回类型参数列表在父类,不会改变,只会重写内容。
所以B类的func函数,参数列表里面的val实际上是1
问答题:
1.什么是多态?
多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态
2.为什么要在父类析构函数前加上 virtual 修饰?
与子类析构函数构成多态,确保析构函数能被成功调用
3.什么是重载、重写、重定义?三者区别是什么?
重载:同名函数因参数不同而形成不同的函数修饰名,因此同名函数可以存在,并且能被正确匹配调用
重写:父子类中的函数被 virtual 修饰为虚函数,并且符合“三同”原则,构成重写
重定义:父子类中的同名函数,在不被重写的情况下,构成重定义,父类同名函数被隐藏
重载可以出现任何位置,只要函数在同一作用域中,而重定义是重写的基础,或者是重写包含重定义,假设因为没有 virtual 修饰不构成重写,那么必然构成重定义,重写和重定义只能发生在继承关系中
4.为什么内联修饰可以构成多态?
不同环境下结果可能不同
内联对编译器只是建议,当编译器识别为虚函数时,会忽略 inline
5.静态成员函数为什么不能构成多态?
没有 this 指针,不进虚表,构造函数也不能构成多态
6.普通函数与虚函数的访问速度?
没有实现多态时,两者一样快实现多态后,普通函数速度快,因为虚函数还需要去虚表中调用