目录
多态的定义及实现:
多态的构成条件:
虚函数:
虚函数重写:
虚函数重写的两个例外:
协变(基类与派生类虚函数返回值类型不同):
析构函数的重写(基类与派生类析构函数的名字不同):
C++11: override 和 final
重写,重载,重定义(隐藏)的区别:
多态的实现原理:
虚函数表:
虚函数表的存放位置(静态区?常量区?栈区?动态区?)
多继承的虚函数表:
先看代码:
Student类继承Person类.其中都有成员函数BuyTicket(),并且成员函数都被virtual修饰,这种被virtual修饰的函数我们把它叫做虚函数.
其中的Student我们使用了Person指针指向它,Person类同样用Person指针指向它,同样的调用BuyTicket()函数,但是确实执行各自的成员函数.
像这样,存在继承关系的不同类,我们使用基类指针去调用他们相同的函数,产生不同的行为,我们把这种现象称为多态.
多态也是现实世界的一种特性,就像不同的动物,面对同样的声音所作出的应激反应却不同.
多态是发生在继承体系中,要构成多态,需要满足两个条件:
1.必须通过基类的指针或者引用调用虚函数
2.被调用的必须是虚函数,并且虚函数需要在派生类中被重写.
在类中被virtual关键字修饰的成员函数被叫做虚函数.
派生类中有一个与基类完全相同的虚函数,也就是三同(返回值,函数名,参数列表相同)函数,那么就称子类虚函数对基类的虚函数完成了重写.
注意,派生类中虚函数的virtual关键字可以省略,派生类继承了基类的函数接口,让同名函数保持了虚函数的特性,但是这种写法不规范.
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变
派生类与基类的析构函数如果被virtual修饰,那么析构函数就具有虚函数性质,二者之间构成重写
在底层处理的时候,将析构函数名字的统一换成destructor,是为了满足虚函数的重写条件.
如果析构函数不构成重写的话,析构函数就不会被放进虚表中,父类指针指向子类对象,系统自动析构函数调用时,只会调用父类的析构函数,就像下面,父类指针st维护的是一个子类对象,但是我们希望调用析构时,能够调用自己各自类的析构,但是确是下面的结果,Student对象也只是调用父类的析构:
子类的成员包括父类成员和自己的成员,当我们通过重写和父类指针或引用实现多态时, 同时析构未重写,系统自动调用的将会是父类的析构,相应的系统也只会释放父类成员维护的空间,而子类成员维护的空间没有被释放,就会造成内存泄漏.
另一个问题就是析构函数的调用顺序,还是上面的代码:
这里可以看到,打印的第一个Person是父类对象调用析构,而子类调用析构时,不仅要调用自己的析构,而且还要调用父类的析构,遵循先子后父的顺序.
两个方面的原因:
在继承体系中,子类继承父类的非私有成员,子类中成员的定义顺序是根据继承顺序定义的,就像下面,Student类继承Person 和A,在其成员变量中,会将先继承的成员放到低地址处,先有Person,再有A,最后是自己的成员.
成员变量的顺序是从低地址向高地址存放,释放的时候就从高地址向低地址释放,有点像栈,为了提高效率.
第二个原因是,考虑到子类的成员变量有可能在使用父类维护的空间,如果先把父类释放掉,那么子类成员使用的父类维护的空间就是非法使用,非法使用,高危操作.
被final修饰的虚函数不能再被重写:
override关键字帮助检查重写:
没有override关键字的话,那上面的例子说,你的本意是期望子类BuyTicket()函数完成重写,但是因为某些原因,导致或者函数名,或者参数列表,返回值与父类的虚函数不一致没有完成重写,那么编译器在编译期间或者把这个函数当成一个重载,或者当作一个新的函数,编译器不会报错,直到运行期间发现得到的结果不符合预期,你去寻找bug的时候才意识到这里的错误.
override修饰以后,它告诉编译器,我是一个重写函数,你去我的父类找我爸爸吧,帮我确定一下我的返回值,函数名和参数列表跟我爸的一样,所以override关键字的作用就是帮助我们检查重写.
同时还可以提高代码的可读性
首先先说重写与重定义:
重写与重定义是发生在继承体系中的,当子类继承了父类,子类成员与父类成员有着各自独立的作用域,你通过子类调用一个成员函数,编译器会首先在子类的类域中寻找该函数,如果没有,则到父类的类域中去寻找该函数,那么又会有这样一种情况发生,子类中的函数与父类中的函数存在同名函数,并且参数列表也相同,同时不是虚函数,这个时候,我们就说二者之间构成了重定义,重定义又叫隐藏.
这个时候你该说了,那这不就是重载吗?二者最主要的一个区别就在于同名函数所在的作用域.就像是两个长的很像的人A,B,如果AB出生在同一个家庭,那么AB就是双胞胎(重载),如果AB不再同一个家庭,只能说明二者只是巧合,就像这里的重定义.
我们在回来说重写,重写要求子类与父类的函数签名相同(返回值,参数,函数名),并且要被virtual关键字修饰.
其实如果你最先了解到的是函数重载,你会感觉三者在形式上类似,但是在其背后有着区别,重定义与重载区别是两个同名函数所在作用域不同,并且重定义发生在继承体系中,重定义与重写的区别在于,父类中的同名函数多了virtual关键字.重载与重写,重写不仅同名函数发生的作用域不同,一个在父类域,一个在子类域,并且重写的函数要被virtual修饰.
先看一段代码:
在x86平台下计算结果是8,根据结构体的存储规则,一个整型是4个字节,并且只有这一个成员变量,为什么是8呢? 我们创建一个Base对象b1来观察它的成员变量:
这里除了自己定义的成员变量b以外,还有一个_vfptr成员,_vfptr就是能够实现多态的原理.它被叫做虚函数表.
我们通过下面的代码验证:
Base包括fun1,fun2两个虚函数,fun3普通成员函数和_b成员变量,Derive 继承Base,并且对Base类中的fun1进行了重写,在主函数内实例化了b和d对象.
我们观察_vfptr的内容:
_vfptr存放的内容其实就是地址,是函数地址,每个类都有各自的虚函数表,每个类会把自己的虚函数地址存放到自己的虚函数表中,当子类继承的时候,会将父类的虚函数表拷贝一份到自己的虚表中,如果子类重写了虚函数,就会把该函数的地址修改为重写后新函数的地址,所以重写还有一个名字叫做覆盖,他是从原理层面命名的.
其次,当我们将一个子类对象赋值给父类对象时,这个时候会发生切片,例如将一个Derive对象赋值给Base对象,Base对象中会切割掉属于Derive不属于Base的成员变量,由于二者都有_vfptr,也就是虚表成员,所以Student的虚表不会被切割掉,但是Student中的虚表内容不会保留给父类.
子类对象赋值给父类,子类的虚表不会保留给父类:
将一个Derive对象赋值给Base指针,Base指针中会保留Derive中的虚表:
我们这里直接对虚函数表中的地址进行验证,看是否真的是函数地址:
由于_vfptr是一个地址占四个字节,并且存放在对象空间的开始位置,我们取对象的地址,强转成int*类型,就得到了虚表的地址,但是内存会将其中的内容解读为一个整数,我们对这个地址解引用,找到虚表中的第一个元素,也就是Derive类中,Func1的地址:
其次就是子类如果新增一个虚函数会不会放到虚函数表中?
先查看Derive类中虚表的内容:
然后观察虚表内存:
发现这里多了第三行的一个内容,它是否是Fun3()函数的地址?我们还是取出这个地址然后调用.
成功调用Derive类中新增的虚函数.
所以,当子类新增一个虚函数时,同样会放入到虚表中,但是在监视窗口我们观察不到,同时要知道虚表是以nullptr作为结束标志.
很多网上的答案是给的静态区,我们自己验证一下,具体思路就是我们分别在栈区,堆区,静态区,常量区定义一个变量,并且打印他们的地址,然后将他们的地址与虚表的地址进行比较,在同一个区域的地址相距不会太远:
肉眼可见,虚表存放的位置距离常量区近,所以虚表存放的位置是常量区.
先看验证代码:
首先Derive类继承了Base1,Base2,并且分别继承了Base1和Base2中的虚表,也就是说Derive中现在有两张虚表,Derive类中对Fun1()函数进行了重写,也就是说,重写后的Func1函数地址覆盖了两个虚表中Fun1()的地址.
PrintfVtable()函数实现了对虚表的打印.
并且Derive类新增了一个虚函数,在visual studio中,编译器将新增的虚函数放在了第一个虚表中的最后.
但是需要注意的是,虚表1和虚表2的Fun1()函数地址不同,但是他们的调用结果却是同一个函数.
下面解释一下,为什么多继承中,子类覆盖父类各自的虚表的结果不同:
Base1和Base2都作为Derive 的父类,也就是说,不论是我通过Base1指针还是Base2指针,都能够实现多态,但是还有一个点,关于this指针的问题,
不要忘记Func1()是一个成员函数,他还有一个隐藏的this形参,我们通过不同的父类指针调用Func1()传入的this指针是不一样的:1
Func1()是Derive类的成员函数,this指针当然是需要指向Derive对象的首地址,Base1刚好指向了Derive类的首地址,如果我们通过Base2指针调用Func1()函数,传入的就不是在是Derive类的首地址,所以,在Base2的虚表中.Func1()的地址指向的指令处就是对Base2指针进行偏移的指令,指针偏移后,再正常调用Func1()函数,我们可以通过反汇编代码观察:
放大看吧 (T_T)
在call eax之前的指令都是一样的,从虚表中读取Func1()函数的地址,到call eax时,我们进行跳转,注意每张图的黄色小箭头是当前所在的指令.
可以看到,Base1调用func1()函数,直接jmp到对象的函数处.
Base2则是jmp到一个sub ecx 8的指令处,这个指令就是对Base2的指针进行调整,调整完后直接调用的Func1()函数,也就是说,Base2调用虚函数相当于将该虚函数封装了一层.