什么是多态?通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生不同的状态。
举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。接下来,我们来讲讲他其中的内容及用法。
一、多态的定义及其实现
1.多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件: 1. 必须通过基类的指针或者引用调用虚函数(必须是基类) 2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
2.虚函数与重写
什么叫虚函数?通过之前提到的虚继承我们可以类比,虚函数就是用virtual修饰的成员函数(必须是成员函数)(二者无关联)
那什么是重写呢?我们来看下面这个场景:
子类和父类都写了一个同名的虚函数,按道理来讲它们构成了我们之前讲过的隐藏关系,但不是,这里是重写(或覆盖),隐藏是针对子类和父类的同名变量而言的。重写的结果也是和隐藏一样,把父类的函数屏蔽。(当然也可以理解为隐藏)(重写包含于隐藏关系,但重写函数返回类型,函数名,函数列表均虚相同,更加严格)
这就是一个标准的多态行为(买票情景,p就是全价,s就是半价)。这样我们来看多态的构造条件还是蛮严格的,缺一不可(父类的virtual必须写,子类的可以不写,建议都写)。如果把上面的&去掉就不是多态。
多态调用,看指向对象的类型,调用指向对象的函数;普通调用(非多态),看调用者的类型,调用调用者的函数。也就是说,如果上面不构成重写关系,那么即使我把s传给了函数作为参数,其最后也会调用person的函数(全价)(因为调用者的类型是person)。
3.虚函数的例外
通过以上对多态的介绍我们暂时觉得这个虚函数与多态在实际应用中有非常大的帮助,但这个虚函数重写也有大坑(例外):
(1)协变(基类与派生类虚函数返回值类型不同) :派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解即可,实际用处并不大)
这里的返回值不同不是简单的将前面的一个void改成int或char即可,而是改成父类(子类)的指针或引用
这样虽然他们返回值类型不同但仍构成多态。(不是一定要是A.B类型,只要是父类或者子类就行)
(2)析构函数的重写(基类与派生类析构函数的名字不同)(很重要)
我们先来看下面这个场景:
现在如果我们创建一个Student变量st,根据之前继承的内容它会先调用子类的析构,然后自动的调用父类析构,也就是说运行结果是~Person()\n ~Student(),但我们现在如果delete p1,猫腻就出来了,结果发现它只调用了父类的析构,此时如果我们子类开辟空间的情况下就会造成内存泄露的问题,那到底为什么没有调用子类的析构呢?
此时我们没有加virtual的情况下是不构成多态的,所以它调用析构函数的类型取决于你调用者的类型(因为p1的类型是person,那最后会调用父类的析构而不是子类,这是产生问题的原因,为了解决这个问题我们实现多态来解决)我们在之前提到过,delete函数的运行分为析构+operator delete(),但是实际上,析构函数的名字不是~类名,而是destructor(),也就是说,此时子类和父类的析构函数同名,构成了隐藏。此时如果我们在父类前加virtual就构成了重写(同名的条件在上面已解释),此时调用析构函数的类型就是看指向对象的类型了。
常考问题:析构函数是否建议析构成虚函数? 一定建议!原因如上
4.final与override关键字
override是专门帮你检查你的虚函数是否写好,一般写在派生类的函数之后即可
virtual void Drive() override {cout << "已写好" << endl;}
final关键字的位置与override相同,它的作用是告诉别人这个虚函数不想再被重写,重写就会报错。
除此之外,final还可以用来修饰类(写在类后),表明这个类无法被继承。
二、抽象类
纯虚函数:在虚函数后面加一个=0的函数(好草率)
包含纯虚函数的类叫抽象类,抽象类的特点是不能实例化出对象。那我们实践中到底要他干啥呢?
其实在实践中如果一个类型在现实没有实体对象,不想实例化出对象,就可以设计成抽象类。比如设计一个car类,你实例化不出来,因为你也不知道是什么牌子的车,所以就需要用某牌车的类去继承,但此时仍无法实例化(因为继承了car类就把纯虚函数继承下来了)为了解决这个问题我们就可以在派生类重写虚函数
补充一个知识点,我们在计算一个类的大小时,除了考虑结构体内存对齐的情况,还要多一个指针的大小(如果类中有虚函数的话),这个指针是指向虚函数表的。我们在下面的内容会提到。
三、多态的原理
1.虚函数表
我们先来看下面这个问题,sizeof(Base)的大小是多少?
正常来看应该是只有一个int的大小(4字节),通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(指针数组),虚函数表也简称虚表。这个指针指向了虚函数表的地址而不是这个虚函数的地址,这也是虚函数和普通成员函数的区别之一。那问题来了,一个类中不可能所有函数都是虚函数,那么在调用函数的时候,虚函数可以通过找虚函数表来找到函数的地址,那普通函数呢?
我们下面讲讲是如何找到虚函数表中的函数以及如何去找普通函数的地址。
2.多态的原理
我们先给一段代码
class Base
{
public:
virtual void func1()
{
cout<<"Base::func1()"<<endl;
}
virtual void func2()
{
cout<<"Base::func2()"<<endl;
}
void func3()
{
cout<<"Base::func3()"<<endl;
}
private:
int _b=1;
};
class Derive:public Base
{
public:
virtual void func1()
{
cout<<"Derive::func1()"<<endl;
}
private:
int _d=2;
}
void func(Base*p)
{
p->func1();
p->func3();
}
int main()
{
Base b;
Derive d;
func(&b);
func(&d);
return 0;
}
经过测试我们发现:
对象d中也有一个和b不一样的虚表指针,即基类b对象和派生类d对象的虚表是不一样的,我们发现func1完成了重写,所以d的虚表冲存放的是重写的Derive::func1,故重写也叫做覆盖,重写是语法的叫法,覆盖是原理层的叫法。
另外func2继承下来后是虚函数,放进了虚表,func3也继承下来,但不会放进虚表。
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己 新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是 他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那虚表存在哪里了呢?经过测试我们发现在常量区代码段。
我们用一张图来表示上图代码的虚函数之间的关系:
那么多态的原理就是,我指向哪个对象,那么调用函数时就会进入哪个虚表进而调用该虚表内对应的函数(指向derive调用func1,那就会调用derive::func1而不是base::func1,但要是调用func2的话就是base::func2,因为没有构成重写)至于func3,普通函数的调用就不会有区别了,直接调用就好了。
3.动态绑定与静态绑定
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载
动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
像上面的func3就是静态绑定,不满足多态,编译时即绑定,在符号表中找函数地址,与指向的对象没有关系。func1,func2就是动态绑定,在运行时,根据所指向的对象在对应的虚表调用函数。
————
本文完,查缺补漏的小伙伴可以看看下面的问题哦,求点赞!
1. 什么是多态? 2. 什么是重载、重写(覆盖)、重定义(隐藏)? 3. 多态的实现原理?4. 什么是抽象类?抽象类的作用?