初识C++之多态

一、多态的概念

  1. 多态的概念

多态是C++三大特性之一。从行为上看,多态和函数重载是比较相像的。因此,也有人说函数重载就是静态的多态。因为函数重载是在编译时绑定,即在程序编译时就已经确定了程序行为。而下面讲的都是动态的多态,就是运行时绑定,即在程序运行时根据拿到的类型确定程序的具体行为

简单来讲,多态就是去完成某个行为,当不同的对象去完成是会产生不同的效果。例如买票这个行为,普通人买是全价,学生去买是半价,军人去买就可能会有更大的折扣。此时,买票这一统一行为,就根据去买票的对象不同,而产生了不同的效果。

  1. 虚函数

虚函数,简单来讲就是被virtual修饰的类成员函数。

  1. 重写

要实现多态,子类中有父类的虚函数的重写是必须条件。重写的形成条件也很简单,在继承的基础上,达成“三同”——函数名、参数、返回值相同。

在上图中,有一个Person父类和一个Student子类。它们中都有一个有virtual修饰的函数,且它们的函数名、参数和返回值都相同,达成三同条件,此时就形成了重写。

当然,如果父类中的函数没有加virtual,而子类的函数加了virtual,此时该virtual不起作用,不会形成重写

  1. 多态的形成条件

多态是在不同的继承关系的类对象,去调用同一函数,进而产生不同的行为。因此,多态的首要条件,就是要有继承关系

要在继承中构成多态还有两个条件:

  1. 必须通过父类的指针或引用去调用虚函数

  1. 被调用的函数必须是虚函数,并且子类中有对应父类的虚函数的重写。即虚函数重写

在上图中,我们创建了三个类,其中Person是Student和Soldier的父类。可以看到,此时我们用Person指针指向了st和sd,按照继承来看,此时应该全部都打印Person里面的“买票——全价”。但是要注意,上面的代码中三个类中都加virtual修饰,是虚函数,满足“三同”条件,且是用父类的指针调用的。因此满足多态调用的条件,调用print()时是调用的指向的类中的print()。

当然,不仅仅指针,使用引用也是可以形成多态的

如果我们去掉virtual,使得此次调用不满足多态,就会全部打印Person中的内容:

同样的调用方式,仅仅只是去掉virtual使得多态条件不满足,打印的内容就变回了原样。由此可以知道,多态调用与普通调用不同,在调用类中函数时,普通调用是根据调用对象的类型来调用对应的类中的函数;多态调用则是根据指针/引用指向的类型来调用对应的类中的函数。

注意,在继承的虚函数中,如果父类中的函数加了virtual形成虚函数,则子类中的同名函数也被视为虚函数,可以不用加virtual

原因是子类在继承父类时,也会把虚函数继承下来,当把虚函数继承下来且子类中有对应函数可以构成重写后,编译器就会将继承下来的虚函数的实现重写。但是要注意,虽然重写的是实现,但是如果父类的虚函数的参数有缺省值,编译器也会一并将参数的缺省值继承下来,即子类的虚函数的参数中也会有和父类一样的缺省值、

虽然可以不用加virtual,但是还是建议加上,显得规范一点,也能提高代码的可读性。

  1. 协变

协变是重写中的一种特殊情况。要构成重写,必须是继承中父类的虚函数要达成“三同”。但是并不是所有情况下都要达成三同。如果函数满足协变,就可以在返回值不同的情况下,依然构成重写

协变的条件就是,要求返回值必须是一个父子类的关系的指针和引用

在上图中,父子类的虚函数的返回值不同,但是是父子关系的指针,此时也是构成重写的。运行测试程序:

可以看到,虽然返回值不同,这里依然是多态调用,说明此处的虚函数依然是构成了重写的。当然,这里返回的指针,子类可以返回父类的指针,但是父类不能返回子类的指针。原因很简单,子类中有父类的成员,但父类中没有子类的成员,如果父类返回子类的指针,那么子类的数据就寻找不到进而报错。

在实际中,协变几乎很少用到,所以大家了解一下即可。

  1. 类中的析构函数

在继承中,子类要析构时,不用显式调用父类的析构函数,因为编译器遵循“先构造的后析构,后构造的先析构”的准则。创建子类时会先调用父类的构造函数进行构造,再构造自己。因此,在构造时是父类先构造,子类后构造。当需要析构子类时,就是先析构子类,再析构父类,编译器在析构完子类后会自动调用父类的析构函数进行析构,无需我们自己显示调用

当了解了多态后,其实就建议给继承关系中的类的析构函数都加上virtual形成虚函数。现在写以下测试用例:

在该测试用例中,我们用两个Person指针指向了新开辟Person和Student空间。运行该测试用例:

可以看到,此时仅仅只析构了两个Person空间,而Student的空间却没有被析构。发生了内存泄漏。

其原因在于delete的行为。

delete在使用时,会先去使用指针调用对应的析构函数释放空间,然后再去调用operator delete(指针)释放指针。上图中的问题就处在调用析构函数上。上面说了,普通调用是根据调用对象的类型来调用对应函数。如果不将父子类中的析构函数加上virtual形成虚函数,就会导致如果我们用一个父类指针指向一块开辟的子类空间时,delete是普通调用,就只会调用子类中父类的析构函数去析构,而不是去调用子类的析构函数析构,进而出现只析构了父类的空间,未析构子类的空间的情况。

在上图中就将父子类的析构函数加上了virtual形成虚函数,此时就会形成多态调用,释放p2指向的空间时就是根据p2指向的空间的类型是Student,然后去调用Student的析构函数完成析构。

有人可能会有疑惑,要形成多态就要达成父类指针或引用去调用虚函数和虚函数重写,这里虽然达成了父指针调用虚函数的条件,但是父子类中的析构函数的函数名并不相同,无法达成重写所需的“三同”条件,怎么能够使用多态调用呢?其实这就是编译器进行了优化,在编译器内部对类的虚构函数进行了特殊处理,它们的函数名一律为destructor。所以虽然我们肉眼看析构函数的函数名不同,但是在编译器内部析构函数的函数名都是相同的。

在以后实现类时,如果要使用继承,就一定要给父类的析构加上virtual。

  1. 重载、重写和隐藏的对比

重载。重写(覆盖)和隐藏(重定义)的差别主要是在函数作用域函数名上。

  1. C++11中新增的override和final关键字

当需要在父类和子类中形成虚函数构成重写时,可能会由于疏忽,导致函数名或者某些地方写错了。如果此时进行编译,编译器是找不出这个错误的,甚至可能不会报错。但这回导致你的程序运行的结果不正确。为了解决这一问题,C++11中就提供了override和final两个关键字,用于帮助用户检测是否重写

8.1final关键字

final关键字,用于修饰虚函数,表示该虚函数不能再被重写

在以前,如果想写一个不能被继承的类,可以将该类的构造私有。因为要实例化一个子类,就要去调用父类的构造函数将子类中的父类部分构造出来,但是如果父类的构造函数是私有的,那么虽然父类的构造函数在子类,但出于不可见状态,子类无法调用。通过将构造私有的方式,就将一个类设置成了无法被继承的类。

当然,如果将一个类的构造私有,不仅无法被继承,也无法在外部被实例化:

在C++98中,要定义一个无法被继承的类时,就是用构造私有的方式。但是这种方式并不直观,因此C++中又提供final关键字,在一个类定义时加上final关键字,也会使得该类无法被继承,这种类被称为“最终类”

上面的只是将final用在类上。其实,final关键字还可以用于虚函数中。当父类中的一个虚函数被final修饰时,该虚函数无法被重写

当然,这样看起来可能有点奇怪,因为在类中实现虚函数,一般都是为了实现重写,final修饰虚函数禁止重写看起来可能有点多余,但是如果在实际中真的遇到这种情况,需要指定某个虚函数不能被重写,就可以使用final关键字。

因此,final有两种使用场景,一种是形成“最终类,另一种就是禁止某个虚函数被重写

8.2override关键字

override关键字,用于检查派生类是否重写了某个基类的某个虚函数,如果没有重写则编译报错

与final关键字放在父类中不同,override关键字是放在子类中的,用于帮助检查子类中的某个函数是否完成重写:

  1. 抽象类

虚函数后面写上“=0”,则这个函数为纯虚函数包含了纯虚函数的类叫做抽象类(也叫接口类)抽象类不能实例化出对象。当然,子类继承了一个抽象类后,该派生类也不能实例化对象。因为子类继承一个抽象类时,也会将纯虚函数继承下来,导致子类也变成抽象类。纯虚函数可以实现,但是一般不会实现纯虚函数,仅仅只会进行声明。

只有重写纯虚函数,子类才能实例化出对象。纯虚函数规定了子类必须重写该虚函数,纯虚函数也更体现出了接口继承:

可以看到,当在子类中重写纯虚函数后,就可以正常实例化了。

在实际中,抽象类中可以存放也写不需要实际定义的函数。例如有一个类,这个类中放了car()函数,然后通过继承的方式继承给了其他子类。此时就可以将父类的car()弄成纯虚函数,强制子类必须重写该函数。因为父类中的car()是一个总称,里面不需要任何数据,仅仅只是给其他子类提供一个car()接口。而子类中的car()的数据就会根据各自品牌的不同存放不同的数据。

注意,抽象类不能做函数返回值和函数参数,因为无法实例化出对象。但是可以做指针类型

  1. 接口继承与实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承则是一种接口继承,派生类继承的是基类虚函数的接口,并未继承虚函数的实现,目的是为了重写以达成多态,继承的是接口。所以,如果不实现多态,就不要把函数定义为虚函数

二、多态的原理

  1. 虚函数表

在了解多态的原理之前,先来看一下以下这个类的大小:

很多人可能会认为是8,字节。因为一个4字节int和一个1字节char,再加上对齐补上3个字节,就是8字节。但是它的大小实际是12字节。原因就在在于这个类中存在一个虚函数。

如果一个类中存在虚函数,那么在计算这个类的大小时,要加上一个指针的大小,32位系统下是4字节,64位系统下是8字节。

那么为什么有虚函数的类中会多一个指针呢?其实类中的虚函数在调用时不是直接调用的,是需要通过“虚函数表(虚表)”来查找的。当一个类中有虚函数时,这个类在构造时会在它的构造列表隐式生成一个函数数组指针,指向虚函数的位置:

可以看到,在程序中并没有写Person类的构造函数,但是通过调试窗口,可以看到多了一个“_vfptr”指针。这个指针叫做“虚函数表指针(虚表指针)”。这个指针指向的内容就是类中虚函数的地址。这个虚表指针,从指向的类型上看,可以看成一个函数指针,它指向的就是一个函数指针数组

上图中就是一个包含了虚函数的普通类的模型。但是virtual一般是用于在继承中实现多态的,因此,如果父子类中都包含了虚函数,这个虚表是怎么样的呢?在对子类实例化时,究竟是父子一人一份,还是共用一份呢?用以下代码来测试:

打开监视窗口,查看pn和st的情况:

通过监视窗口就很清晰了。在测试代码中创建一个父类Person对象和一个子类Student对象。通过观察_vfptr指向的内容可以看到,父子类的_vfptr[0]上的内容不一样,但是_vfptr[1]上的内容一样,指向同一个位置。原因就是,在上面的父类中的两个函数虽然都是虚函数,但是一个在子类中重写了,一个没有重写。子类的虚表中的第一个指针就是指向重写后的虚函数的。因此,在继承中,子类的对象是有两份虚表的,一份是父类的虚表,一份是子类的虚表。

重写,也叫做覆盖重写其实就是从语法上面看的,指的是父类的虚函数将自己拷贝到子类中后,重写该虚函数的实现覆盖则是从底层来看,指子类的虚表其实是通过类似拷贝的方式将父类的虚表拷贝到自己的空间当中,然后查看虚表中指向的虚函数有没有被子类重写,如果重写了就将新的虚函数的地址覆盖上去,没有则保持不变。当然,在实际运作中肯定不是通过拷贝这种方式实现的,这里只是为了好理解才说是类似拷贝。

同时要知道,同一个类型实例化出的多个对象,它们都是共用的同一个虚表。下面是测试代码:

调出监视窗口并查看pn1和pn2的虚表:

可以看到,它们的虚表指针所指的都是同一个位置,并且其中的内容也是相同的。

有了上面的对虚表的理解,就可以推导出多态调用为什么可以根据指针指向的对象类型调用不同的虚函数了。

其实多态的实现就是依赖的虚表。当使用多态调用时,程序也并不知道要调用哪个虚函数,都是在需要使用,到指针指向的对象中去找虚表指针,根据虚表指针找到虚表的位置,然后再从虚表中找到对应的虚函数的位置进行调用。因此多态调用可以根据指针指向的类型调用不同的虚函数就是因为有虚表存在。

注意:虚表是放在常量区(代码段)中的

三、多继承中的虚函数表

上面的内容讲的全部都是单继承中多态的情况和单继承的虚表结构。接下来,再来讲讲多继承中的多态和虚表结构。

先写出如下测试代码:

在上面的测试代码中,写了三个类,Base3继承了Base1和Base2两个父类。在Base1和Base2中有两个相同的虚函数func1()和func2(),而子类Base3中有一个重写的虚函数func1()和一个没有重写的虚函数func3()。

typedef中重命名了一个函数指针,名字为VFPtr。函数指针的重命名方式和普通的重命名有所不同。

在上面的测试代码中,Base1()和Base2()其实和普通的类没有什么区别,就不过多讲了,重点是子类Derive.

按照以前的理解,子类在继承父类时,会将虚表继承下来,所以现在的子类derive可以看成下图所示:

为了验证猜想,现在实例化一个Derive类并查看监视窗口

通过监视窗口可以看到,Derive类中确实继承了Base1和Base2的两个虚表。但是有一个很奇怪的问题。这里虽然继承了父类的虚表,但是在测试代码中,Derive自己还有一个没有重写的虚函数func3(),为什么这里却看不见它呢?其实这里就是因为监视窗口看到的并不是变量的原生状态,而是经过了处理的。Derive中的func()其实是存在的,只是监视窗口经过了处理,在这里不显示

既然func3()存在,那就又衍生出了一个问题。如果只是单继承,那么func3()肯定就是放在继承下来的虚表中。但是这里是虚继承,继承下来了两份虚表,那么这个func3()放哪份虚表呢?

为了方便看到func()的地址,再写一个PrintVFTbale()函数来打印虚函数的地址:

此时就可以通过调用该函数来打印虚表中存储的内容了。写出以下调用:

要知道,虚表中存的是函数指针,在32位系统下是4字节,64位系统下是8字节。要看到虚表中存储的内容,就需要一次性打印出4个字节的内容。但是类指针解引用出来就是一个类的大小,很明显不合适。所以要传指针时要传一个解引用后是4字节或8字节大小的指针。因此将类指针强转为一个二级指针解引用,二级指针接引用后是一个一级指针void*,此时它解引用后就是4个字节或8个字节的大小,符合需求。里面的“vft[i]”是一个虚函数指针,用于调用该虚函数指针指向的虚函数。

运行上面的程序:

分别将两个虚表打印出来后可以发现,第一份虚表中有三个地址,且第三个地址就是指向的Derive类中的func3()。而第二份虚表中只有两个地址。这就说明,在多继承中,子类中未重写的虚函数的地址是放在第一个被继承的类的虚表中

菱形继承中的虚表结构就不用太多关注,因为本身就非常不推荐写出菱形继承,能用就不用。第二个就是菱形继承的虚表结构很复杂,也基本不会考,所以只用了解多继承的虚表结构即可,无需过多了解菱形继承的虚表结构。

四、多态的一些常见问题

  1. inline函数与虚函数

inline函数从语法上看,是可以成为虚函数的。但是如果一个inline函数加上了virtual修饰,那么编译器在多态调用时会忽略inline属性,该函数就不再是inline函数,而是虚函数,进入虚表。因为inline函数在编译时就会在调用的地方展开,但是一旦这个inline函数成为了虚函数,它就需要进虚表,在运行时绑定。这就导致程序在编译时遇到该函数的调用,此时程序并不知道它要调用虚函数在哪里,导致无法展开。

但是上述是多态调用的情况。如果这个加了virtual的内联函数在调用时用的是普通调用,那么就可以在调用的地方正常展开,保持inline属性。因为普通调用虚函数时,不会到虚表中找,而是直接去对应的作用域找

  1. 静态成员与虚函数

static静态成员是不能成为虚函数的,因为静态成员函数中没有this指针,只能使用“类型::成员函数调用方式”来访问,无法访问虚表,也就无法将其放入虚表中。因此static静态成员是不能成为虚函数的。

  1. 构造函数与虚函数

构造函数不能是虚函数。因为虚函数需要进虚表,而虚表的初始化时在构造函数的初始化列表中最后进行的,也就是说,类的初始化列表一结束,就会将类的虚表指针初始化出来。如果构造函数是虚函数,那么要调用构造函数就需要进入虚表去找地址,但是此时虚表指针并未初始化出来,无法正确访问。

  1. 一个对象访问普通函数和虚函数的速度比较

如果使用普通调用,那么访问普通函数和虚函数的速度是一样的,因为普通调用无需进入虚表找虚函数位置,而是直接去对应的作用域中找。但如果使用多态调用,那么调用普通函数的速度会更快。因为多态调用访问虚函数时,需要到虚函数表中去找虚函数的位置,然后访问。

  1. 虚函数表生成阶段

虚函数表是在编译阶段生成的,一般放在代码段(常量区)

  1. 虚函数表与虚基表的区别

虚函数表是用于存放虚函数地址的表。而虚基表则主要是在出现菱形继承时,用于解决数据冗余和二义性问题的,里面存储的是子类到父类的偏移量,用于找到父类数据的位置。

  1. 父类的对象无法形成多态调用

多态的条件之一,就是用父类的指针或引用去调用。父类的对象是无法形成多态调用的。原因就是用子类对象给父类对象赋值时,切片的过程中并不会将虚表一起切片过去。这就导致父类的对象中只有自己的那份虚表,没有子类的虚表,无法找到子类重写后的虚函数的地址

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值