本篇主要讲C++三大特性之一 —— 多态。
想看继承的同学可以点这里:【C++】继承知识点详解
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
其实就是就是指不同人干同一件事情产生不同的结果。
拿我们生活中的例子来说。
吃饭
正常人吃饭,吃一碗就够了。
大胃王吃饭,吃十碗可能都不够。买票
如果普通人买票,那就是正常价位。
如果是学生买票,就是学生票。
如果是军人买票,就是军人优先。等等例子。
那么怎么用代码实现这些东西呢?
就是用多态的知识。
学习多态,首先要学的就是虚函数。
下面就用买票的例子来用代码简单演示一下。
还是用到了virtual这个关键字,为什么说还是呢?
前面我讲继承的时候有个虚继承,里面也用到了virtual这个关键字,但是虚继承中的virtual是为了解决菱形继承的数据冗余和二义性的问题的。这里虚函数的virtual和虚继承中的virtual没有半毛钱关系。只是同一个关键字两用了而已。
虚函数:被virtual修饰的类成员函数称为虚函数。
长这样:
注意:只有成员函数才能加virtual,全局函数是不能加virtual关键字的。
上面函数名形同可不是继承中的隐藏了。
是虚函数的重写(或叫做覆盖),就是从父类继承下来的虚函数要进行重写,但是重写要求三同:函数名相同、参数相同、返回值相同,而且要重写的父类中的函数必须是虚函数。
不符合重写就是隐藏关系。如果我在student的BuyTicket参数中加个int,那么就不是重写了,其和继承父类中的BuyTicket就是隐藏关系。
上面就简单介绍了下虚函数的条件,而实现多态不光要有虚函数,还要让父类的指针或引用去调用虚函数。
我们来测试一下:
调用了各自的函数。
如果我们此时将引用去掉:
就变成了全是调用父类中的函数。这里就是继承中子类给父类赋值的切片。
还都是全票。还是切片。
但是子类虚函数不加virtual依旧构成重写。
因为其认为的就是先把虚函数继承下来,然后在子类中进行重写,重写的是继承下来的函数的实现。
但是我们实际写的时候最好还是加上。
但是注意看报错信息,里面有协变这两个字。
其实这里返回值也有特例:返回值可以不同,但是返回的必须是有父子关系的指针或引用。
而且父类必须返回父类的指针,子类必须返回子类的指针。
看(这里把军人类注释掉):
person* 和 student* 是有父子关系的指针。
先看段代码,猜猜结果是多少?
class Base
{
public:
virtual void func()
{
cout << "func()" << endl;
}
protected:
int _a = 1;
char _ch = 'c';
};
int main()
{
cout << sizeof(Base) << endl;
return 0;
}
答案:
为什么是12呢?
因为有一个指针,什么指针呢?
一个指向虚函数表的指针,什么又是虚函数表呢?
就是存放一个类中所有虚函数地址的一张表,其底层就是一个函数指针数组(不要被名字吓到,很简单,就是一个数组,数组里面存放的元素类型是函数指针(函数的地址))。注意虚函数表存放的是函数的地址,而函数可不是存放在虚函数表中的,是存放在代码区的。
所以说,比起没有虚函数的类中,此处的大小就会多算一个指针的大小。而且再考虑上内存对齐的话就是12个字节。
再来看段代码:
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 _a = 1;
char _ch = 'c';
};
调试起来看看:
可以看见,func1和func2都为虚函数,二者的地址都进了虚函数表,但是func3不是虚函数,所以func3的地址没有进虚函数表。
最关键第一点就是,虚函数的地址会存进虚表(虚函数表)中。对象中是没有虚表的,有的是虚表的指针。
上面这两点一定要记清楚,不敢记错。
那么当虚函数重写了之后呢?
给出如下代码:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void PersonFunc() { cout << "Func" << endl; }
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
int _b = 0;
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
其中,PersonFunc继承后没有被重写,BuyTicket继承了之后被重写了。
调试起来,看看两个类的对象模型:
仔细看,Johnson中的虚函数指针是继承自父类中的,而且其中BuyTicket被重写了,所以其地址是和父类对象的BuyTicket地址是不一样的,PersonFunc没有被重写,所以二者的地址一样。覆盖这的就是虚函数重写之后,将重写的虚函数覆盖到虚函数表中原函数的位置。
当调用Func的时候,Func参数的为Person& p
传参为Mike时,直接通过Johnson对象虚函数表指针所指向的虚表中的Buyticket的地址来调用其Buyticket函数,就打印出 买票-全价。
传参为Johnson时,发生切片,将Person类继承下来的东西传给了p,再直接通过Johnson对象虚函数表指针所指向的虚表中的Buyticket的地址来调用其Buyticket函数,就打印出了 买票-半价。
所以多态的本质原理,就是符合多态的两个条件,那么调用时,会到对象的虚表指针所指向的虚表中的虚函数地址来进行调用其虚函数。
当不符合多态的条件时,就是普通函数的调用,比如说我将virtual关键字去掉,调用的时候就直接在编译链接阶段就确定函数的地址就行了,运行时直接调用。继承下来的函数只要没有同名,那么就调用的是父类的函数。
而虚函数的调用是运行期间还要去虚函数表中去找一下虚函数的地址,然后再调用。
这里涉及到一个问题,就是继承下来的虚函数,子类没有去重写,是会按照虚函数去调用还是按照普通函数去调用?
答案是虚函数。
为啥说这个呢?
因为要涉及两个东西,一个是编译时决议,一个是运行时决议。
普通函数是在编译时就能决定函数的地址的,在运行时直接去call函数地址就快好了;而虚函数需要在程序运行时才能去对应的对象的虚表指针中去找函数的地址。所以相较与普通函数而言,虚函数调用起来会更慢一点。
这里继承下来的没有覆盖,但是编译器还是按照虚函数去调用继承下来的函数的。
我们可以通过反汇编来看看:
两次调用func都是这样走的call eax,就是走的虚函数的调用。
如果是普通函数就不是这样了:
会去直接call普通函数的地址,因为普通函数的地址已经在编译阶段确定下来了。
所以说就算没有真正将虚函数的内容改变,只要提供了接口,就会走多态的那条路。
一些专业术语:
相关的一篇博客:C++ 编译时多态和运行时多态
请选择:
首先一点,test继承下来时this指针不会变为B类的指针,还是A*。
不信的话,看看二者的地址:
所以当p调用test时,调用的是A中的test。p传给了A*的this,p的类型为B*的,发生了切片。
此时再在test中调用了func,注意是this->func(),而且this是A*的,子类B中func也发生了重写,那么就是父类的指针调用重写的func,所以就调用了B中的func,但是还有一点是这里是虚函数继承,是接口继承,func的接口还是A中的那个 virtual void func(int val = 1), 只是重写了实现,也就是func的内部实现变成了std::cout << “B->” << val << std::endl; 所以就相当于是头没换,换了个身子。所以拼到一块就是virtual void func(int val = 1) {std::cout << "B->" << val << std::endl; }
,所以打印的结果就是B->1。而我们前面普通函数的继承是实现继承,也就是头和身子都会换。
那我再改改:
然后下面的过程就和上面的题目一样了。
所以答案还是B->1。
析构函数可以定义为虚函数吗?
可以的。
如果没有将析构函数写为虚函数:
class Person {
public:
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
~Student() { cout << "~Student()" << endl; }
};
那么程序运行起来会出问题:
p1没啥问题,p2就有问题了,p2指向的是一个子类的空间,按理说应该将子类的空间也释放掉的,但是delete之后并没有。
如果我改成virtual来看看:
这样就能既调用子类的析构,又能调用父类的析构了,但是为啥呢?
我前面讲继承的那篇博客也说了,一个函数的析构函数的函数名会被统一处理成destructor,这也就能使得子类对析构函数进行重写。
当delete的时候,delete内部会进行两个关键操作,一个是调用析构函数,再一个是释放空间。
如果析构函数加上了virtual就可以调用到子类的析构,因为这里满足了多态的条件(父类指针,子类虚函数重写),然后子类的析构函数结束后会自动调用父类的析构函数。所以两个函数都调用了。
若无虚函数,只会调用person的析构函数,就是一个普通的调用,编译时决议,运行的时候直接call析构函数,但是此时子类就没有调用。
所以子类的析构函数重写父类的析构函数,这样才能正确调用。父类指针指向父类对象时调用父类的析构函数,父类指针指向子类对象的时候去调用子类的析构函数。
这两个关键字有点用。
前面讲继承的时候也说到这个关键字了。
但是继承中的final是为了不让某个类被继承。
这里多态中的final是用来不让某个虚函数被重写的。
这个关键字是用来检查虚函数是否完成重写的。
如果完成了重写,就不会报错。
如果没有完成重写,比如我这里在子类的虚函数参数处加个int:
这里也就印证了前面没有重写的那块讲解。
看见这三个词,你能瞬间捋清楚吗?
就是函数重载。
要求如下:
这是多态中的虚函数重写。
要求如下:
这是继承中的子不承父业。
要求如下:
这个名字抽象吗?
其实起这个名字有其意义的。
先不说为啥有意义,先看看长啥样。
这个名字更抽象。
非常虚的函数。像星爷电影《西游降魔篇》中的肾(空)虚公子一样。
好了不开玩笑了。
纯虚函数就是在虚函数后面加了一个坤哥的蛋。
包含纯虚函数的类就是抽象类,就这么简单。
注意一点:抽象类不能定义对象。
比如说我们现实生活中,肯定有我们只能通过文字或其他方式来描述的东西,没有其具体实例,就像物理中的力一样,只能画个图来看看,现实中是不可能看到的。比如说一个写一个植物类,不能通过植物类来定义出一朵花来,这样定义出的对象不够具体,我们可以再写一个玫瑰花类,让玫瑰花类来继承植物类,然后用玫瑰花类来定义对象。那么我们就可以将植物这个类搞成抽象类,让其不能定义对象。
我上面写的纯虚函数没有函数体,看上去好像就一个声明,但其实是可以写的,但是写了之后又没有用,因为定义不了对象,所以也就用不了,直接不给函数体就好。
派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
来个例子:
// 抽象类
class Car
{
public:
// 纯虚函数
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-非常舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控很好" << endl;
}
};
一般用父类指针,然后子类虚函数重写,就可以实现多态了。
如果子类没有重写纯虚函数,那么子类也会变为抽象类,因为子类继承了父类的纯虚函数。
重写了之后就可定义了:
再强调一下:纯虚函数相当于强制让子类去进行虚函数的重写,不重写就用不了。虚函数的继承是接口继承,继承的是函数接口,用的是子类的函数实现。
不使用多态就不要搞虚函数,不然用多了会影响整个程序运行的效率。
同一个类型的对象共用一个虚表。
不管是否完成重写,子类的虚表跟父类的虚表都不是同一个。
再写出如下子类:
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func2() { cout << "Derive::func2" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
调试:
可以看到,父类对象和子类对象的虚表的地址是不一样的。
但是上面我在子类中定义了4个虚函数,两个是重写的,两个是新增的,vs这里显示的有点问题,看不到那两个新增的虚函数,不过我们可以通过内存来直接查看虚表:
续着内存下面还有两个很像地址的东西,而且与fun1和func2的地址很接近,其实就是fun3和fun4的地址,但是光看这里的话还不能确定,我们可以再在监视窗口中用查看数组的方式来查看_vfptr:
如果各位还是不相信的话,这里可以写一个打印虚表的函数,但是写之前要讲点东西。
如果有的同学指针没有那么强的话,下面的内容实在听不懂不做强求,因为这些也不是重点的。可以直接跳到打印虚函数地址的那块。
虚函数表,原理就是一个函数指针数组,就是一个存放函数地址的数组。
怎么表示呢?
首先函数指针被强转成void(*ptr)()的,然后再加上数组:void(*arr[])()。
这里需要各位C语言指针学的比较扎实才能看懂,我这里就姑且认为大家都会了,如果不懂的同学可以看看我好久以前C语言阶段写的有关指针的博客。
但是函数中直接这样写有点麻烦,我们可以typedef一下函数指针(注意这里不是重命名函数指针数组)。但和普通的typedef不一样,需要将重命名的名字写在类型中:
然后再用这个来定义数组就是 VFptr[ ]。然后我们就可以把这个当做参数来打印所有的虚函数。
vs中会将虚函数表中最后一个有效地址的下一个位置存放为空指针(如果不是就清理一下解决方案再重新生成一下),我们可以利用这一点来打印虚函数表。
通过看Vftable的每一个位置是否为空来判断结束:
这样就好了。
不过还有一个细节,就是函数名就是函数的地址,这个也是C语言中的知识,我们这里可以直接通过函数地址来调用函数,就像这样:
然后这个打印虚函数表的函数就完成的差不多了。
注意这里我是从第0个开始打印的,而我下面所有的打印虚表的截图中虚函数的实现都是没有func0的,所以打印出的func[i]中的i各位看的时候自动加个1,知道我意思就行。
下面再说一个很绕的问题,就是我们如何传参?
还是指针,但是这里要将虚表的地址传过去非常麻烦。
我们来看着我们的形参,函数指针数组,vftable为数组名,数组名代表首元素的地址,而首元素的类型为vfptr,所以首元素的地址的类型就是 vfptr*,所以我们传参时就要传这个类型,是不是听起来很麻烦,没关系,我来给你讲。
我们现在有两个对象,一个父类的,一个子类的。
想要打印这两个的虚表,就要先搞清楚二者的对象模型是什么样的。
有一点,对象中虚表指针一直是存放在对象地址中的开头位置,也就是说在32位的机器下,b1或d1的内容中的前四个字节存放的就是其对应的虚表指针(64位就是8字节),那么我们就可以利用到这一点。
&b1取到的是Base*,是整个对象的地址,解引用后就会取到整个Base对象的内容,但是如果只想取出前四个字节的内容,各位有什么好方法吗?
不知道各位知不知道如何判断一个机器是大端还是小端?
如果单纯用指针的话,就定义一个int的变量,取值为1,然后用char*来取这个变量的地址的第一个字节,然后看第一个字节是1还是0,是1就是小端,是0就是大端。
那么这里取前四个字节就好说了,一样的思路,只不过我们用的是int*来取对象中前四个字节的内容。我们可以直接来个强转为int后再解引用,这样就能取出前四个字节的内容,像这样:*(int*)&b1。但是这个解引用之后得到的只是一个int数,不是一个地址,而是一个纯数字,所以怎么改成地址呢?
还是将这个int的整数强转一下,就直接强转成Vfptr*就可以了,此时就是虚函数表指针中的内容。也就是这样:(Vfptr*)*(int*)&b1。看起来就非常的麻烦。
能看到这里的同学,相信你的指针确实很强,那么我们就来看看虚表的打印结果:
再调试看看:
这样总能相信了吧。
上面的打印虚表在不同的平台下是不一样的,比如在g++下虚表中最后一个元素的下一个位置就不是空指针了。所以此时就不能用表中元素是否为空来判断了,就必须要改为制定打印元素个数,比如这里我们是知道每个虚表中有多少个虚函数的,所以就可以直接控制这个个数来打印,就像这样:
这样就可以看出,在单继承下,父类和子类中都是只有一个虚表,子类多写的虚函数会续着继承下来的虚函数一同放到虚表中。而且父类和子类的虚表是不一样的。
如下图所示:
Base1的大小为8,最大对齐数为4;
Base2的大小为8,最大对齐数为4;
d1大小为4,所以整个类的最大对齐数为4,8 + 8 + 4就是20,为4的倍数。
所以最终结果就是20。
再来看一下继承关系:
子类中重写了func1函数,添加了一个func3虚函数。
看起来太乱了,我来剪裁一下:
又是没有显示出新添加的func3,没关系,等会打印的时候就能看到了。
这里子类继承了两个类后,重写func1,使得原来继承下来的两张虚表中的func1都被覆盖掉了,但是注意看,两个func1的地址并不相同。这个问题我们等会讲,这会先确定func3在哪张表中。
通过内存我们能发现在Base1继承下的表中。
然后我们用那个函数来打印一下其中的虚表:
但是此时如何传参呢?
我们是先继承的Base1,后继承的Base2。
那么Base2呢?
也好说,我们根据前面画出来的对象模型就能粗略算出,Base1和Base2继承下来,两者相差了8个字节。
那么我们就能用指针加整数来得到Base2中虚表的地址。
差8个字节,怎么加呢?
方法不限,我们可以先将对象的地址转为char*的,加1就能跳过一个字节大小了,所以总共加8就能得到想要的地址。如果不强转,对象的地址直接加1,跳过的就是整个对象的大小。直接就把前四个字节跳过去了。当然也可以强转为int,但是总数加2就够了,因为一个int是4个字节,加2就跳过了8个字节。
得到地址后,就按照前面单继承那里传参就好了。
不过还可以直接加上sizeof(Base1),因为char*类型加上sizeof(Base1)就能跳过Base1到达Base2,这里适用于你不知道Base1大小的时候。
但是还有种更简便的方法,就是切片。
直接父类指针赋值即可。
因为切片会直接将子类中父类的那部分给父类。
下面再说子类中两个虚表的func1的地址不相同的原因。但是要知道这个问题的话要懂一点点反汇编。如果不懂的同学可自行跳过,这也不是什么重点。
这里就给一个略图。
通过以下代码调试:
ecx是用来存放this指针的。
虽然func1地址不相同,调用的时候虽然走的路不同,但最后还是走到了一块,调用了同一个函数。
前两种调用方式,二者的this指针都能指向d的首地址,但是ptr2只能指向中间的Base2 的地址处,所以右侧下方会有个sub ecx, 8 (8不是固定的,这里是因为Base1的大小为8)这个指令,就能使得ptr2指向最开始的位置。
继承和多态还是非常重要的,笔试的选择题中会出一些,还有面试的时候会有问答题。
这里就出一些简答题:
- 什么是多态?
不同对象去完成某种行为时产生不同的结果。
- 什么是重载、重写(覆盖)、重定义(隐藏)?
详情请看本篇。
- 多态实现原理
多态的实现分为静态多态和动态多态的实现。
1、静态多态主要是同名函数的重载,在编译的时候就已经确定。编译器会根据函数实参的类型(可能会进行隐式类型转换),来确定具体调用哪个函数,如果有对应的函数就调用该函数,否则会出现编译错误。
2、动态多态主要是父子类同名函数的覆盖,运行时的多态,是用虚函数机制实现的,在运行期间动态绑定。编译器在编译的时候,会为每个包含虚函数的类创建一个虚表和虚表指针。该表是一个一维数组,在虚表中存放了每个虚函数的地址。程序运行时,会根据对象的实际类型来初始化虚表指针,让虚表指针指向所属类的虚表。在调用虚函数的时候,能够根据函数地址找到正确的函数。
————————————————
版权声明:本文为CSDN博主「追梦偏执狂」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_46111138/article/details/125216386
- inline函数可以是虚函数吗?
不可以但编译器允许,内联函数没有地址,而虚函数的地址要放到虚函数表中,编译器允许是因为inline只是个建议的关键字,编译器可以不接受inline,当一个函数是虚函数后,多态调用中inline就失效了。
- 静态成员函数可以是虚函数吗?
不可以,static函数没有this指针,可以直接用类域来访问,虚函数是为了动态多态而实现的,动态多态都是运行时去虚函数表中找函数地址的,静态成员函数都是在编译时就决定地址的,成为虚函数没有价值。
- 构造函数可以是虚函数吗?
不可以,虚函数是为了实现多态,运行时去虚表中找对应的虚函数进行调用,对象中虚表指针都是在初始化列表处才初始化的,这样就矛盾了。
- 析构函数可以是虚函数吗?
可以。基类指针指向子类地址时,析构函数就要写成析构的。拷贝构造可以吗?
不可以,拷贝构造也是构造,也有初始化列表。operator=可以吗?
可以,但是没有什么价值。
多态是将子类对象地址或值赋值给父类指针或引用,如果重写复制重载的话,子类参数要么改为父类的指针,要么改为父类的引用,但是根本没有这个必要,语法上已经足够我们使用了,不需要画蛇添足。再说也没这个使用场景。
- 对象访问普通函数快还是访问虚函数快?
虚函数不构成多态调用时,一样快。
虚函数构成多态调用时,普通函数快,因为多态调用是运行时决议,需要在运行时去虚表中找虚函数地址。
- 虚函数表是在什么阶段生成的?
虚函数表是放在常量区(代码段)的,在编译阶段形成。注意构造函数初始化列表处初始化的是虚表指针,对象中存的也是虚表指针,不是虚表。
- C++菱形继承的问题有哪些?虚继承的原理?
看我继承的博客:【C++】继承知识点详解
- 什么是抽象类?抽象类的作用?
详情请看本篇博客。
到此结束。。。