C++进阶-多态
文章目录
- C++进阶--多态
- 概念
- 多态的定义及实现
- 多态的构成条件
- 实现多态的两个重要条件
- 虚函数
- 虚函数的重写及覆盖
- 协变
- 析构函数的重写(面试被问及最好需要举例说明)
- override和final关键字
- 重载,重写(覆盖),隐藏(重定义)的比较(要记忆)
- 纯虚函数和抽象类
- 多态的原理
- 虚函数表指针
- 多态的原理
- 多态是如何实现的
- 动态绑定与静态绑定
- 虚函数表
- 结语:
我们今天又见面啦,给生活加点impetus!!开启今天的编程之路!!
我们来完成面向对象的最后一部分–多态
作者:٩( ‘ω’ )و260
我的专栏:C++进阶,c++初阶,数据结构初阶,题海探骊,c语言
欢迎点赞,关注!!
C++进阶–多态
概念
多态,就是多种形态,分为静态多态(编译时多态)和动态多态(运行时多态),静态多态主要是函数模版和函数重载,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态。
之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运行时归为动态
动态多态:具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态
多态的定义及实现
多态的构成条件
多态是一个继承关系的下的类对象,去调用同一函数,产⽣了不同的行为
实现多态的两个重要条件
1:必须是基类的指针或者引用去调用虚函数
:2:被调用的函数必须是虚函数,并且完成了虚函数的重写和覆盖(必须满足三同,即返回类型,函数名,参数的类型和个数,特殊情况协变除外)
注意:要实现多态效果,第⼀必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派⽣类对象;第二派⽣类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派⽣类之间才能有不同的函数,多态的不同形态效果才能达到。
这里我们来举例查看多态的效果:
这里可以看到虽然都是Animal的对象在调用talk,但是跟animal没关系,而是由ptr指向的对象决定的。
满足上面多态的条件,此时是形成多态调用的。调用同一个函数,产生了不同类型的效果。
再来看一个例子:
接下来我们来详细介绍:
虚函数
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。(一定要是类的成员函数)
如下:
class Animal
{
public:virtual viod talk(){}
}
class cat:public Animal{
pubilc:virtual void talk(){//重写实现的行为}
}
虚函数的重写及覆盖
虚函数的重写/覆盖:派⽣类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
细节:重写如何理解呢?
如果构成重写,其实就是基类中虚函数的声明加上重写函数的实现即可。所以,其实virtual可以不用再派生类的重写虚函数中写出,但是在基类中必须要写出virtual,但是一般不要这样写,这样写代码的可视化降低了。
本质:重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性)
所以在上面给出的示例代码中,如果你尝试将派生类的virtual去除,其实也是能够得到理想结果的,但是如果去除基类的virtual,就将无法构成多态调用。
协变
前面我们提到了重写的概念,必须要满足三同(返回类型,函数名,参数类型和个数),但是有了协变之后,为了处理某些特殊情况,即使返回类型不同,也能够构成多态,条件是返回类型必须是父类和子类类型的指针或者引用。
即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
来看例子:
我们再来想一下,必须是本身的基类和派生类的类型呢?可不可以是其他的基类和派生类的类型呢?是可以的,来看代码:
协变在实践中的意义不大,仅做了解即可。
析构函数的重写(面试被问及最好需要举例说明)
我们平常写析构函数都是构造函数,再来取反得到析构函数,例如:
class A{
public:A(){}~A(){}
};
因为构造函数是固定的,所以析构函数就是固定的,如果我们要来重写虚函数,就无法满足三同中的函数名相同,为了解决这个缺陷,编译器会将析构函数统一转换成destructor(),就能满足函数名相同了。
结论:基类的析构函数为虚函数,此时派生类析构函数只要定义(说明此时派生类中定义了资源,需要手动析构资源),无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写
为什么还要来进行析构重写呢?我们来看下面这段代码:
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};
class B : public A {
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};
// 只有派⽣类Student的析构函数重写了Person的析构函数,下⾯的delete对象调⽤析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调⽤析构函数
int main()
{A* p1 = new A;//基类指针或引用调用虚函数A* p2 = new B;//基类指针或引用调用虚函数delete p1;delete p2;return 0;
}
这里delete一个自定义类型,会转换成p1->~A()+operator delete()(delete自定义类型会调用自定义类型中的析构函数以及free函数)。
我们为什么这里要设置成指针呢?目的是满足多态调用的第一个条件-基类的指针或者引用调用虚函数。而且此时满足虚函数的三同,我们来查看结果:
此时我们调用了b的析构函数,将b中的new的资源给释放掉了,如果这里我没有写成多态(将基类中析构前面的virtual删除),来看结果:
此时b中new的资源并没有被删除,造成了内存泄漏。
复习一个知识点:为什么这里顺序是这样的呢?
构造是先基类后派生类,析构是先派生类后基类。
上面构成了多态调用,与p1和p2无关,只与后面的对象有关,先析构A对象,后析构B对象,因为B是派生类,析构完B后还会去析构一下基类A,所以顺序是如此
这个知识点:提问一般是为什么基类的析构函数建议设计成虚函数?
override和final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰
基类加了final的话就无法被继承了!!
final在基类成员函数定义的最后来加,override在派生类成员函数定义的最后来加,来看示例:
重载,重写(覆盖),隐藏(重定义)的比较(要记忆)
来比较一下三者(经常考):
有些时候隐藏也被叫作为重定义。
理解:隐藏和重写有相同的地方,他们可以看作为重写的条件比隐藏更加严格即可。
纯虚函数和抽象类
在虚函数的后面写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派⽣类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
抽象一般都是在现实生活中无法存在的事物,现实生活中都不存在,就没有必要实例化对象出来了,自然只给声明就好。给定义无意义,因为会被重写的
在虚函数后面写=0是在基类的虚函数后面写这个
class Car
{
public:virtual void Drive() = 0;
};
class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒适" << endl;}
};
多态的原理
虚函数表指针
先从一个例题引出:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
protected:int _b = 1;char _ch = 'x';
};
int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
来看结果:
为什么是12,而不是8呢?我成员变量不是只有一个int和一个char吗?按照内存对齐规则应该就是8啊。
是因为里面还有一个vfptr的指针数组放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)
在32位(x86)环境下指针的大小是4个字节,64位(x64)环境下指针的大小是8个字节。这个指针叫做虚函数表指针(表就代表是数组)。
⼀个含有虚函数的类中都至少都有⼀个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
多态的原理
多态是如何实现的
从底层的角度UseFun函数中ptr->talk(),是如何作为animal指向Animal对象调用Animal::talk(),animal指向cat对象调用cat::talk的呢?animal指向dog对象调用dog::talk的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派⽣类就调用派生类对应的虚函数。第一张图,animal指向的Animal对象,调用的是Animal的虚函数;第二张图,ptr指向的dog对象,调用的是dog的虚函数。cat同理。
动态绑定与静态绑定
对不满足多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址,叫做静态绑定。
满足多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。
我们来看一下静态绑定和动态绑定的反汇编代码:
虚函数表
结论:
1:基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表(防止无用的消耗),不同类型的对象各⾃有独⽴的虚表,所以基类和派⽣类有各⾃独⽴的虚表
2:派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的(即派生类和基类中的虚函数表vfptr的地址不同)
3:派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址
4:派⽣类的虚函数表中包含,(1)基类的虚函数地址,(2)派⽣类重写的虚函数地址完成覆盖,派⽣类⾃⼰的虚函数地址三个部分
5:虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000标记,g++系列编译不会放)
6:虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的(vs上),只是虚函数的地址⼜存到了虚表中
结语:
感谢大家阅读我的博客,不足之处欢迎留言讨论,感谢大家的支持!!
锲而舍之,朽木不折;锲而不舍,金石可镂,加油!!