本期我们来学习面向对象的三大特征的最后一个,多态,本期难度较大,希望大家可以静下心来学习
目录
多态的概念
多态的定义及实现
多态的构成条件
虚函数
虚函数的重写
虚函数重写有两个例外
C++11 override 和 final
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
接口继承和实现继承
多态的原理
虚函数表
动态绑定与静态绑定
单继承和多继承关系的虚函数表
单继承中的虚函数表
多继承中的虚函数表
问答题
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了Person 。 Person 对象买票全价, Student 对象买票半价。那么在继承中要 构成多态还有两个条件 :1. 必须通过基类的指针或者引用调用虚函数2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数必须是成员函数
这里全局函数就不能是虚函数
虚函数:即被virtual修饰的类成员函数称为虚函数
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
有了虚函数后我们就可以重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用
多态就是根据不同的对象,可以调用不同的函数 ,多态调用看的是指向的对象,普通对象看的是当前类型
另外一定要记住多态的两个条件,一个是调用函数是重写的虚函数,另一个是基类指针或者引用
首先是子类重写的虚函数可以不加virtual,父类不可以
原因是这里叫做重写,重写是重写的实现,而且我们是继承下来的,不过我们一般建议加上virtual
第二个叫做协变
协变 ( 基类与派生类虚函数返回值类型不同 )派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
简单来说就是返回值可以不同,但是要求返回值必须是父子类关系指针和引用
这里就是协变,不过协变很少使用
协变必须同时是指针或者是引用,不能一个指针一个引用
另外,析构函数加virtual也是虚函数重写,因为析构函数被处理成了destructor这个统一的名字
为什么要处理为这个名字呢?因为要让他们构成重写,为什么要构成重写呢?
我们看一个场景
这里就发生了内存泄漏,这里是为什么?
delete会先调用析构函数,析构函数的名字被统一处理为destructor,所以是p->destructor(),然后要调用operator delete(p),前者是普通调用,和当前的类型有关,p的类型此时是person,但是我们这里是不期望调用person的析构的,我们是期望指向谁调用谁,所以我们期望p->destructor()是一个多态调用,而多态的条件有两个,其中一个已经达成了,这里是指针,p->,还需要一个virtual,虚函数重写,就可以构成多态
这里构成重写, 问题就解决了
我回过头来再看,子类可以不加virtual,所以我们把所有的父类加上virtual,这样就有很多的好处,如果我们去看库里面,想要被继承的都会加上virtual,我们可以认为virtual就是为了这里而设计的,不过virtual也不要全部加上,毕竟是需要一定的代价
从上面可以看出, C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug 会得不偿失,因此: C++11 提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
final:修饰虚函数,表示该虚函数不能再被重写
final修饰后就不能被重写了
除非这样写,这里就是隐藏了
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
如果我们想设计一个类,这个类不想被继承,我们该怎么设计呢?
第一个方法是C++98的,我们将构造函数私有化就不能被继承了
因为私有是不可见的,而派生类的构造函数必须调用父类的构造函数,不过此时我们的A也不能创建对象了,我们可以用这个办法
我们给A提供一个函数,这个函数返回A对象,但是我们用的时候就出现先有鸡还是先有蛋的问题
所以我们这里加上static,问题就解决了
下面我们再看第二种办法设计这个类
我们把析构函数私有化,不过这里还是一样的问题,不能创建A
不过我们可以new一个,释放的话和上面一样,提供一个static的函数
C++11更新了final后,我们可以更方便一点
直接加一个final就可以了
所以final有两个作用,一个是修饰类,使类不能被继承,另一个是修饰虚函数,让虚函数不能被重写
概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。 包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象 。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
就像这里Car就是一个抽象类,无法实例化对象
Benz也是同理 ,只要包含纯虚函数的类都是抽象类,这里的Benz是继承的
想要解决的话重写一下就好了
那这里我们该如何使用呢?
我们可以定义一个Func,我们虽然不能实例化,但我们可以用car的指针,当我们传一个Benz时,就会调用Benz的Drive ,传BMW时会调用BMW的Drive
这里Car对象是没有虚表的,而派生类对象是有虚表的
纯虚函数这里其实间接的强制了派生类重写虚函数,override是检查虚函数是否重写,二者还是有区别的
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
我们先看这里,这为什么是8呢?
他有一个bfptr的指针, 还要对齐,所以计算一下就是8
如果我们多加两个func ,都在里面,如果我们把func3的virtual去掉,那么func3就不在vfptr这里了
我们可以看到,mike对象由两个成员构成 ,一个是vfptr(虚函数表指针),另一个是_a
Johnson有三个成员
画出来就是这样的,如果Johnson里重写了,那虚表指向的就是重写的虚函数
这就是为什么指向父类调父类,指向子类调子类,由于切片,切出来的都是父类,如果不符合多态,在编译时就确定地址,根据函数名修饰等找到地址,而如果是多态,运行时到指向对象的虚函数表找调用函数的地址
这是符合多态时的调用
这是不符合多态时的调用
我们明显可以看到符合多态时的指令都变多了
下面我们来看一道题
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
这个重写就是两个红色的部分拼在一起,所以传的val还是1
这道题大家要知道三个点,首先是this指针是A*,第二个是符合虚函数的重写,第三个就是重写是重写实现,用的是父类的壳,这也是为什么子类可以不加virtual
现在我们回过头来,我们想一想为什么多态的条件是父类的指针和引用以及虚函数重写?
为什么子类的指针和引用不行?原因是只有父类的指针才能指向父类和子类,而子类的指针只能指向子类对象
那为什么不能是父类的对象呢?
对象的切片和指针的切片是不一样的
我们把他们画出来
就是这个样子
我们再看指针和引用,它可以指向父类对象,在父类里面可以看到父类的虚表,指向子类可以看到子类中父类那一部分的虚表
重写是虚表的位置完成覆盖
拷贝之后,用重写的虚函数地址来覆盖这个地址
指向父类对于指针来说,看到的是父类的对象,如果是父类对象,那就是父类对象,如果是子类中的父类对象,那就是子类的虚表,而对象的切片是不同的
也就是说,指针和引用的切片是不存在拷贝的,而对象的切片是需要拷贝的
首先这里的_a是肯定会拷贝过去的, 那虚表会不会拷贝呢?
先说结论,不会拷贝
我们这里对a的值进行修改
而虚表没有拷贝 ,那为什么不拷贝虚表呢?
如果这个过程中我们把虚表拷贝过去 ,再用父类的指针指向父类对象反而会调用子类,就出问题了
结论:子类赋值给父类对象切片是不会拷贝虚表的,如果拷贝虚表,那么父类对象的虚表中是父类的虚函数还是子类的虚函数就不确定了
这里也就解释了多态的条件为什么不能是父类对象
现在我们再看多态的第二个条件,虚函数的重写,是重写实现,所以有些地方会有一个概念,普通的函数继承叫做实现继承,而多态,虚函数的重写是一种接口继承
第二个条件为什么是虚函数重写,只有完成了虚函数重写,派生类的虚表里才能是派生类的虚函数,才能做到指针指向父类调父类,指向子类调子类,这两个条件是严格把控的,是有自己的原因的
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
这里没有给0 ,这是编译器的问题,算一个bug,我们清理,重新生成一下
这里就有了
总结一下派生类的虚表生成: a. 先将基类中的虚表内容拷贝一份到派生类虚表中 b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
监视窗口是看不到func3的,所以我们用内存窗口看,我们怀疑这里就是func3 (后面我们会验证)
这里我们也对比出了内存和监视窗口是有不同的
还有一个问题,虚表是存在哪来的?栈,堆,数据段(静态区),代码段(常量区)
首先我们排除堆,因为堆是留给我们动态申请的,虚表是由编译器生成的,其次我们排除栈,因为同类型的对象共用一个虚表,栈是跟随栈帧走的
比如这里的ps1,st1,ps和st是公用虚表的
我们可以看到,虚表的地址是一样的
如果存在栈里,应该存哪个栈帧呢?而且函数结束时,栈帧销毁,虚表也要销毁,后面用的时候还要重建,所以排除栈
其实虚表是存在代码段的,我们来验证一下
通过这样打印,我们发现虚表距离常量区很近,距离其他的还是有一段距离的
下面我们验证一下func3的那个问题,监视窗口看不到,而内存窗口有一个地址,我们怀疑那个地址是func3
虚函数表,简称虚表,本质是函数指针数组
我们这样就把虚表里的东西打印出来了,对比内存里的最后一行,刚好是虚表里的(这里ps打印出了很多东西,这是程序后面0那个bug,重新生成解决方案就可以,我这里没有)
但是我们现在还是不能百分百确定这就是func3,不过我们有了函数指针就可以调用这个函数
我们再借助这两行代码调用函数, 上面我们给func123都设置了内容,这里就打印出来了(大家可能发现这次ps只有012,第二个是0123,而我们上面打印的第一个ps却很多,这就是我们之前说的程序后面的0这个bug,我这里是重新生成了解决方案,不重新生成我这里甚至会崩溃,这是vs的问题)
此时我们就确认了之前的地址就是func3
了解了这些知识,我们就要明白,监视窗口是不靠谱的,内存窗口才是靠谱的,我们要留意一下
1. 静态绑定又称为前期绑定 ( 早绑定 ) , 在程序编译期间确定了程序的行为 , 也称为静态多态 ,比如:函数重载2. 动态绑定又称后期绑定 ( 晚绑定 ) ,是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态 。3. 上面 买票的汇编代码很好的解释了什么是静态 ( 编译器 ) 绑定和动态 ( 运行时 ) 绑定。
静态(编译时)的多态, 就像函数重载,根据不同的情况调用不同的函数
ps和ptr调用函数是不一样的,ps是在编译的时候就确定了地址,ptr是在运行后去指向的对象的虚表里找到地址,然后再call进行调用
动态(运行时)多态,是通过继承,虚函数重写实现的多态
需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的
单继承的虚表问题就是我们上面的func3问题,这个我们已经解决了,这里就说一下上面代码的思路
思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
1.先取b的地址,强转成一个int*的指针
2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
4.虚表指针传递给PrintVTable进行打印虚表
5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 - 生成 - 清理解决方案,再编译就好了。
typedef void(*FUNC_PTR) ();
//打印函数指针数组
void PrintVFT(FUNC_PTR* table)
{
for (size_t i = 0; table[i] != nullptr; i++) {
printf("[%d]:%p\n", i, table[i]);
FUNC_PTR f = table[i];
f();
}
printf("\n");
}
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
我们这里把上面的打印虚表代码再拿过来
为什么这里是20呢?
base1和base2都是一个虚表的指针加自身的成员,是8字节,加上d本身的成员,4字节就是20字节,派生类本身不产生虚表,因为继承的父类那一部分里就有虚表,所以不需要
我们发现,func3在第一个虚表里,而且两个func1的地址不一样
为什么重写了func1,但是Base1和Base2的虚表中func1的地址不一样?
虽然二者的地址不同,但最后调用的是同一个
我们通过反汇编来看
我们看第一个地址,eax里的是0x006c123f,Base1的也是这个,然后call
我们知道调用是一个jump,jump后面跟的才是真的地址
jump跳到了这里 ,006c2840,后面的h是16进制的意思
所以base1里的第一个地址就是正常的地址,我们再看第二个
这里是0x006c134d
我们再看jump ,这里的jump和上面的jump地址不一样,而且后面跟的地址也不一样
我们再走的话就到了这里,没有jump到函数的地址,这里对ecx减去了8,ecx存的是this指针的值
再jump就到了这里,006c123f ,这个地址是不是很熟悉?没错,就是base1那里的jump
这里我们就明白了,他们最终都调用的是同一个函数,只是第二个多走了几步,多走几步是为了ecx-8
那为什么要-8呢?
ptr2这里-8就到了上面的位置,func1是Derive的成员函数,this指针应该指向Derive对象,因为ptr1恰好指向了Derive的开始,是重叠的,所以ptr1不需要动,而ptr2指向的是base2
如果大家仔细看上面的图片,其实是有伏笔的 ,这里是this指针,ptr1那里也有
-8的原因就是为了修正this指针,让ptr2也指向Derive
调用时分为两部分,一个是传this指针,一个是call地址,ptr1和ptr3是可以直接传的,虽然类型不一样,但在内存里不管类型,因为都指向一个位置,而ptr2的指针位置是不对的,不敢直接call,所以会进行修正
最后再补充一些问答题
1. 什么是多态?答:参考本期内容2. 什么是重载、重写 ( 覆盖 ) 、重定义 ( 隐藏 ) ?答:参考本期内容3. 多态的实现原理?答:参考本期内容4. inline 函数可以是虚函数吗?答:可以,不过编译器就忽略 inline 属性,这个函数就不再是inline ,因为虚函数要放到虚表中去。5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有 this 指针,使用类型 :: 成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。参考本节课件内容8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段( 常量区 ) 的。10. C++ 菱形继承的问题?虚继承的原理?答:参考继承课件。注意这里不要把虚函数表和虚基表搞混了。11. 什么是抽象类?抽象类的作用?答:参考( 3. 抽象类)。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
以上即为本期全部内容,希望大家可以有所收获
如有错误,还请指正