C++多态知识点深入了解

Posted Booksort

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++多态知识点深入了解相关的知识,希望对你有一定的参考价值。

多态

介绍

多态的定义
函数调用的多种形态执行不同的行为,达到不同的目的

多态分为静态的多态和动态的多态

  • 静态的多态:函数重载(函数的参数列表不同),在编译时确定
  • 动态的多态:基类指针或引用调用基类与派生类的虚函数,在运行时确定

静态的多态

最常见的多态,是函数重载,也就是静态的多态。
我们都是使用一个函数,但是操作的对象的类型不同,有内置的,也有自定义的。仿佛一个函数可以同时执行这么多的类型的对象,看上去是一个函数,实际上不是.

官方重载了这么多函数

其实还有跟多的重载。

你也可能会说,是利用模板进行的泛型编程。
但是,实际上,模板<template T>在没被对象的类型实例化之前只是一张图纸,被实例化后,依旧是函数重载,属于静态的多态。

动态的多态

动态的多态
不同类型的对象,去执行同一件任务,产生的动作是不一样的,执行的过程是不一样的,产生的结果也是不一样的。

比如,买票
不同类型的对象:军人,儿童,学生,成年人

同一件任务:买票

执行的过程/产生的动作:

  • 军人需要出示军人相关的证件证明自己军人的身份和一些相关材料以及身份证来表明自己的身份信息
  • 儿童需要展示自己的身高以及身份信息来证明自己符合购买儿童票的条件
  • 学生需要初始学生证来证明自己是在校大学生以及身份证来证明自己符合学生票的优惠
  • 成年人仅仅需要出示自己的身份证来购票即可

产生的结果:不同类型的人员购票的价格不一样。

满足动态的多态还需要两个条件

  1. 必须通过基类的指针或引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数完成重写

所以,动态的多态要在继承的环境下使用
且根据继承的知识,基类的指针或引用可以指向派生类(切片原则)。且基类的指针或引用可以指向继承它的不同的派生类

虚函数

虚函数

定义:被 virtual 关键字修饰的成员函数(非静态) 必须是成员函数,修饰普通函数直接报错。 在某基类中声明为 virtual 的成员函数,并且在其派生类中被重新定义的成员函数。
用法:virtual 返回类型 函数名(参数列表);

注意事项

  1. 只有类的非静态成员函数可以是虚函数
  2. 多态的 virtual 与 多继承中的虚继承机制使用的 virtual 毫无关系

虚函数的重写

重写也叫覆盖,派生类中需要有一个跟基类中的虚函数完全相同的虚函数,就是说,两个虚函数要 返回类型,函数名,参数列表 完全一样,则派生类的虚函数才能完成基类虚函数的重写


这就是一个函数由于接受参数类型的不同,导致执行了不同的输出结果
这个看似调用了一个函数,实际上还是多个函数,由于参数的类型不同,导致了调用了不同的函数,实现了调用的多种形态,这些是在运行时确定的。

对于多态条件的破坏

多态实现的条件
基类的指针或引用调用虚函数,派生类的虚函数必须对基类的虚函数完成重写
如果破坏一个,多态都不能实现,
如:

基类调用虚函数

参数列表不同

虚函数重写的三个例外

虚函数重写的三个例外

  1. 协变:基类与派生类的虚函数的返回值类型不同
    派生类重写基类虚函数时,可以返回值类型不同,但是必须要求基类的虚函数的返回值类型为基类的指针或引用,派生类的虚函数的返回值类型为派生类的指针或引用
    满足这个条件,派生类也能完成对基类虚函数的重写。(返回类型要一致,不能指针对引用)

  2. 对于析构函数之间构成的重写
    基类与派生类之间的析构函数之间是可以构成重写的,那怕析构函数的名字不一样。
    析构函数是不需要也不能有参数的,所以参数方面不用考虑,析构函数也没有返回值,也不用考虑。
    对于析构函数,最后编译时,编译器回将析构函数的函数名处理成 destructor() 。所以,析构函数是能构成重写的。
    析构函数的作用:主函数结束时,自动调用,去清理类申请的资源。

  3. 派生类的虚函数可以不写virtual,只要基类写了virtual,这样也能满足重写。
    如果基类的派生类的派生类Person->Stu->A。只要Person的虚函数有virtual,那怕Stu和A都没加virtual,但是A与Stu中的虚函数依旧构成重写。而且经过实验,Person与A类中的虚函数依旧符合重写,依旧满足多态。
    所以,只要最原始的基类的虚函数有virtual,其继承下的分支类中的对应虚函数都能完成重写(相当恶心)。如果基类不加,就只能构成隐藏了,甚至就没有虚函数。

但是前两种例外都比较少见,在比较特殊的情况下才会使用。对于第三种,应该是个设计缺陷,尽量不要这样

协变

析构函数之间构成重写
对于普通情况下,在析构函数之间还没有构成重写时

输出结果,是这样的,对于基类指针指向派生类,delete时不会调用派生类的析构函数,也就是说,对于派生类申请的资源是不会清理的。
如果派生类中没有申请资源,那都不会有什么问题。但是如果派生类申请了资源,但是在为构成重写的情况下,就会造成内存泄漏的情况。
所以最好还是将析构函数构加上virtual,让其构成重写。

派生类可以不加virtual

因为派生类继承基类,派生类也继承了基类的virtual修饰的虚函数,这样其virtual的属性也就随着继承保留下来了,所以其虚函数不写也拥有重写基类虚函数的能力。

这样的设计,可以方便派生类的析构函数,但是有些得不偿失,造成更大的缺陷

final关键字

如果加在类后面,那么可以禁止类被继承
如果加再virtual虚函数后面,那么可以禁止虚函数被重写

override关键字

加在派生类的虚函数的后面,可以检查身份完成重写,未完成就会报错。
什么也没有不会报错,会认为你完成了重写。
像参数列表不一致,返回类型不符合要求都算没完成重写。这个可以多加使用。

额外知识点

  • 只有需要实现多态时,才将虚函数加上virtual,启用虚函数,因为实现多态是需要付出代价的。
  • 关于虚函数在类中声明,类外定义时,virtual只需要在类中声明前加上即可,类外是不用添加的,而且也加不上,因为一加上就会报错,virtual是不能在类外使用的。
  • 多态分为编译时多态和运行时多态,也叫早期绑定与晚期绑定

重写,重载,重定义的区别

我列了一张表

抽象类

要求类中要有纯虚函数。基于OOP语言的特性,封装一直是我们描述对象的关键,但是现实世界中,并不是所有的对象都能拿类来描述,更多的是相当抽象的,我们可以通过抽象类作为基类,去描述某些抽象的对象,比如,食物,植物等等。
让抽象类去定义一些纯虚函数,然后让派生类去继承,如果派生类想要实例化的话,就必须实现这些纯虚函数,也就是基类定义一些接口,然后让派生类去实现。这个体现了接口继承。

继承抽象类的派生类如果不去实现这些纯虚函数(全部实现),那么派生类由于继承了纯虚函数,则派生类自己也会变成抽象类。所以抽象类强迫想要实例化的派生类去实现自己的纯虚函数。

纯虚函数

定义:
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现/定义,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
纯虚函数也叫抽像函数,纯虚函数只有函数名,返回类型,参数列表,没有函数体,也就是说没有定义,需要强迫派生类的虚函数去实现
有纯虚函数的类叫做抽象类,且抽象类是不能实例化出对象的。

纯虚函数与override的区别。

纯虚函数强制想要实例化的派生类去实现虚函数的重写,对于不想实例化的类也没有要求。
override会去检查派生类的虚函数,如果没重写就不能用,直接报错。

接口继承与实现继承

实现继承是派生类继承基类已经实现好的成员函数。派生类就可以直接使用。继承基类的接口和定义,普通的成员函数。

接口继承
就是只继承基类的接口,对于函数的定义自己去实现。纯虚函数和虚函数就是典型的接口继承。

虚表


_vfptr也叫虚函数表指针,其指向一个函数指针数组,数组中的每个元素都是指向一个虚函数的首地址。
继承中的那后果叫做虚基表,是为了解决菱形继承中基类成员相对于对象首地址的偏移量。

对于

而言,Car基类的引用去接受派生类的对象,其虚函数表,_vfptr指向的,当执行虚函数时,就去Ben类的虚函数表中查找,然后执行。所以说,多态的原理就是执行虚函数时,就去虚函数表中查找,然后执行。指向那个对象,就去那个对象的虚函数表中进行查找。这个也不违反切片原则。

在构成多态的情况下,跟接受的对象有关,基类指针或引用指向那个对象就调用哪个象的虚函数
在不构成多态的情况下,跟接受对象的类有关,哪个类接受的对象,就执行哪个类中的函数。

为什么多态的条件是指针或引用,对象不行?

_vfptr指向的虚函数指针数组就相当于是每个类的配置一样,有且只有一份。
所有同样类型的对象共享同一个虚函数表。
切片原则,只会把基类有的成员变量从派生类对象中切过去,对于_vfptr中的地址,是不可能给基类对象的。

看例子

汇编层次实现多态

提问:虚表指针是在什么阶段完成初始化的呢?虚表又是在什么阶段生成的?

虚表指针是存在于对象中的,所以在对象还没生成时,是不会有虚表指针的,而对象完成构造后,虚表指针又是正常出现了,所以虚表指针是在对象在构造函数中的初始化列表中完成初始化的。
而虚表是要早于虚表指针的,不然虚表指针就拿不到虚函数指针数组的首地址。而在一个程序的编译阶段会处理程序中的函数,对函数进行分析,所以虚表是在编译阶段生成的。当对象构造时,直接将地址赋给指针即可

提问:虚表是存放虚函数的,对吗?

不对,对于正文代码,一旦程序完成编译后,代码就会放进进程地址空间中的正文代码段,而虚表全称
虚函数指针数组。虚表中的每个元素,存放的都是虚函数的首地址。多态时,指针或引用直接可以通过虚表去查找然后调用。一个类中所有的虚函数的地址都会放进虚表中,非虚函数是不会的。

有一个疑问,那纯虚函数会不会放进需表中。很明显不会,纯虚函数在基类中,基类是无法实例化的,而派生类实例化后,就是虚函数重写了纯虚函数,你根本看不到纯虚函数。

重写与覆盖

重写,官方也叫他覆盖
在语法层,叫虚函数的重写,在原理层,叫虚函数的覆盖。
对于派生类中的虚函数重写基类的虚函数,本质上是通过虚表去调用函数。对于基类的虚表中的虚函数指针而言,当派生类重写了基类虚函数,那么在编译时,派生类就会拷贝一份基类的虚函数指针数组,放进派生类为自己的虚表准备的空间中,然后,看看自己重写了哪个虚函数,就按照其相应的危机去将元素赋为自己重写的虚函数的函数指针。

不过不同的编译器对于末尾元素是否是NULL是由不同的方案的

如何知道虚表生成的位置

可以聚集点不同区域的变量的地址

这个就能得到虚表的地址和代码段的地址很接近,大概率是在代码段

多继承中的多态

如果,两个基类中由一样的虚函数,然后被同一个派生类继承了,那么派生类中会分别有两张虚表,对两个基类的虚表都会进行一次拷贝操作,而派生类自己的虚函数可能会放在第一张虚表中,虚表的顺序取决于继承的顺序。
我的编译器有点问题,不能演示。

练习题

B是A的派生类,B中的f()虚函数对A中的f()进行了重写,在B的虚表指针中,对A中f()的地址进行了覆盖,无论其是不是私有成员,只是调用的函数的对象发生了改变,但是调用该函数的位置没变

A类是B类的基类,A类指针指向一个new的B类对象。
new一个B的对象,要先创建基类的对象。执行A类的构造函数,由于虚表指针在构造函数的初始化列表中就已经完成了,而虚表在编译时就形成了。且这是在基类中,虚表也是基类的。调用A::test()函数,然后调用的func函数也是A类的,且此时,B类的虚表指针还没有初始化好。打印第一次val的值为0。然后A类创建好了,去创建B类的对象,完成B类的指针的初始化,然后调用从A类继承的test()函数,因为B类中没有同名函数,不会构成重写。然后调用fuc()函数,这个就相当于是A类指针去调用被重写了的函数,形成多态,调用B类中重写了的函数。val++然后打印一次。然后还有p->test()函数。由于p指针指向的对象是B类,然后调用B类中继承的test函数,然后又会调用重写的func()函数。

这就是程序的输出结果。

注意

解题时,要记住,虚表指针是在构造函数的初始化列表时完成初始化,虚表在编译阶段就完成了。

使用指针时,要注意接受的对象的类。然后联系虚表,定位调用的函数

在构成多态的情况下,跟接受的对象有关,基类指针或引用指向那个对象就调用哪个象的虚函数
在不构成多态的情况下,跟接受对象的类有关,哪个类接受的对象,就执行哪个类中的函数。

以上是关于C++多态知识点深入了解的主要内容,如果未能解决你的问题,请参考以下文章

解析虚函数表和虚继承

C++ 封装,继承,多态总结

C++ template —— 动多态与静多态

c++ 深入理解虚函数

了解 C++ 中的继承和多态性

C++多态实现原理