【C++】继承多态详解
在之前的CPP大作业中,为了应付期末(是这样的)关于继承和多态部分的内容只是草草过了一遍,并没有深挖背后的实现原理,以及使用的时候一些注意事项。
本篇博客是对类和对象继承多态部分的深化!
[TOC]
0.什么是封装
面向对象的三大特性:封装、继承、多态
面向对象还有 反射(C++中没有)、抽象 等特性
封装:
- 不想让用户在类外访问的成员设计成私有,允许访问的设计成公有。相比C语言没有类和访问管理相比,封装能提高设计的安全性和完整性。
- C语言中,如果设计的不好,不规范编写的代码容易出现错误访问
struct
结构体中的成员。 - 同时,C++中的迭代器设计,也能给一批容器提供基本相同的访问接口,让用户能使用相同的代码,在不暴露容器底层结构的前提下访问容器中的值。
- 暴露底层结构会提高容器的使用成本,代码也比较复杂,不同数据结构也不一样。
stack/queue/prioritiy_queue
的适配器形式,能弄出来我们想要的东西,这也算是一种封装
1.继承派生关系
继承是提高代码复用性的一个重要手段。它允许我们在保持基类原有属性的基础上,对其进行一定的扩张,增加不同的功能以应对实际情况。
比如对于一个人来说,其都会有性别、年龄、身分证号等等信息。但不同职业就还会包含不同职业的特殊信息。这时候就可以通过继承,在基础一个公民的基本信息的同时,再去处理每一个职业的独立信息。这也实现了类在一定程度上的复用,减少了代码复杂性。
与其相似的增加代码复用性的语法,还有模板
1.1 基本用法
继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员
- 继承是一个特殊的语法,用于多个类有公共部分的时候
- 父类:基类
- 子类:派生类
1 | //举例:网站的公共部分 |
在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的
1 | //下面使用继承的方式来写,WEB类是网站的公共部分 |
测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员
同一个类可以同时继承多个基类
1 | class C : public A,public B{ |
1.2 权限问题
继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别
1 | class A{ |
当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的
- 用什么继承方式,派生类中继承的基类成员就变成什么类型;
- 不管用什么继承方式,都无法访问基类中的私有成员;
可以使用 Min(成员在基类中的访问限定符,继承方式)
来计算某一个成员在子类中的访问限定符是什么。
关于权限问题,我们还需要了解下面几点:
- 基类的私有成员在派生类中不可见,但实际上它也被继承过去了。但是编译器和语法的限制让我们无法访问。
- 保护限定符由此出现,如果在基类中的成员不想被外界直接访问,但又需要子类中访问,则可以定义为保护;
- class默认继承方式为私有,struct默认继承方式为保护;
- 实际中我们一般使用public继承,保护/私有方式不利于维护和拓展。
面试的时候可能会考察你xx继承方式,子类可以访问基类的什么成员。实际上,不管是什么继承方式,子类都可以且只能访问基类中public/protected
的成员。
1.3 同名问题(作用域)
在继承体系中,基类和子类都有自己独立的作用域;
当基类和派生类中出现同名成员函数或者同名成员变量时,会出现冲突。这时候编译器会做一定的处理:直接访问变量名和函数名的时候,优先访问派生类自己的成员,而屏蔽掉基类的。
这种情况被称之为隐藏
:
- 函数名相同构成隐藏(并非重载)
- 成员变量名相同构成隐藏
实际操作中,强烈不建议写同名的成员,不管是成员函数还是成员变量
1 | //继承同名成员的处理 |
下方的调用测试能看出结果;
1 | class A |
在这个栗子里面,A::func B::func
两个函数之间是什么关系?
答案:二者是隐藏
的关系,并非函数重载!函数重载要求两个函数是处于同一个作用域,才构成重载!
这点通过编译测试也能看出来
1 | int main() |
当我们使用如上函数进行编译的时候,编译器会报错找不到 B::func()
,因为B的作用域中只有func(int a)
这个需要传递参数的函数。如果A::func B::func
的关系是函数重载的话,那这里应该可以直接调用才对。
1 | test.cpp: In function ‘int main()’: |
只有指定父类作用域才能调用到A::func
1 | B bt; |
1.4 静态成员
在继承体系中,基类的静态成员有且只能有一个。即所有的子类和他们的对象,都是只有那一个静态成员的。我们可以用这个特性来对继承派生中出现的对象进行计数。
1 | class Person |
如果出现了与静态成员同名,访问方法就有所变化
1 | //访问同名的静态成员 |
1.5 友元
友元关系不会被继承,基类的友元函数无法访问派生类的私有/保护
成员
1.6 默认成员函数
我们知道,C++类和对象中有6个默认成员函数
在派生类中,这些默认成员函数有新的使用方法
- 派生类的构造函数必须在初始化列表中调用基类的构造函数,初始化父类的一部分成员。如果你没有写,编译器会自动调用默认构造函数(先调用基类,在调用子类)
- 派生类的拷贝构造同上,必须显式调用基类拷贝构造函数(将子类对象传过去,相当于将子类对象中的父类部分传入父类拷贝构造函数。这部分是编译器自动帮我们实现的切片操作)
- 派生类的赋值重载也需要调用基类赋值重载完成操作
- 派生类的析构函数编译器会自动调用基类,先析构派生类,再析构基类成员(符合栈后进先出原则)
- 在基类析构函数不是虚析构的时候,子类析构和父类析构构成
隐藏
关系; - 因为多态的需要,析构函数会被统一命名为
destructor()
,构造函数并不会出现重命名。
在下方栗子中,当我们写B类的深拷贝的时候,可以通过指定类作用域的方式来调用A父类的operator=
重载(这里必须要指定类的作用域,否则调用的还是B类自己的operator=
重载,相当于无效的递归调用,最终会因为死循环导致栈溢出)
因为我们是将子类赋值给父类,所以都是编译器自动帮我们进行的切片操作。
1 | class A |
而在析构函数中,子类的析构调用完毕后,会自动调用父类的析构,以保证先析构子类,在析构父类。
所以并不需要我们显式调用;显示调用父类析构的时候会报错
1 | test.cpp: In destructor ‘B::~B()’: |
构造和析构顺序
子类继承父类后,当创建子类对象,也会调用父类的构造函数
- 继承中先调用父类构造函数
- 再调用子类构造函数
析构顺序与构造相反
显示调用父类构造函数
如何显示调用父类的构造函数呢,下面是一个代码示例。我们需要在子类构造函数的初始化列表中调用父类构造函数。
1 |
|
运行结果如下,可见子类正常调用了基类构造函数并进行了初始化
这里也涉及到之前学过的一个小知识:在CPP中,类中成员的初始化顺序是依照声明的顺序来初始化的!而基类中的成员声明早于子类成员,自然也是先初始化基类的。
1.7 基类和派生类赋值问题
派生类成员可以赋值给基类的对象/指针/引用。一般我们把这种情况称为切片
,形象地表示把派生类中父类那部分切来,赋值过去给父类对象。
但是!反过来是不行的哦,你不能把基类对象赋值给派生类对象。
基类的指针/引用可以用强制类型转换给派生类的指针/引用,但是这样不够安全,除非基类的指针指向的是对应的派生类。如果基类是多态类型,可以使用RTTI(运行时类型识别)的
dynamic_cast
转换来进行安全处理。
1 |
|
关于最后提到的越界访问问题,我们知道,指针变量的大小都是相同的,其指针类型的区别主要在访问能力的不同。比如char*
指针解引用只能访问1个字节,int*
指针解引用可以访问4个字节,以此类推,Student*
指针解引用可以访问sizeof(Student)
个字节的空间。
而子类对象的大小都是大于等于基类对象的大小的。这就导致子类指针访问基类对象内容时,一次解引用访问的空间超长,造成了越界访问
实际上,当我们切片讲子类对象赋值给父类对象的时候,编译器会进行切片操作,即新的父类对象中的内容只会包含父类的成员。子类多出去的那一部分成员会被剔除。
这一点我们可以在VS的调试中证实
因为基类的成员变量被设置成了保护,所以我们不能直接在外部进行修改。需要显式调用基类的构造函数来初始化基类的成员。
1.8 虚继承(菱形继承问题)
有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类
这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”
比如:intel和amd联合推出的NUC小电脑中,有一款CPU是他们合作开发的
如何解决同时继承AMD和INTEL的问题?
- 这时候会出现两个同名变量,一个是AMD里面有的,另外一个是INTEL里面有的
因为他们是从CPU里面继承来的。 - 虽然我们可以指定作用域来分别修改和访问。但是实际上这个公共部分就出现了浪费(比如是网站的公共部分,多给你一份没有啥意义)
和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。
1 | //解决方法1(治表不治本) |
这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural
关键字(对公共基类的继承添加上虚继承关键字)
1 | class CPU { |
这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。
继承模型
普通菱形继承
下图中的继承模型是一个简单的菱形继承,我们能看到d中关于两个公共A._a
的位置是不相同的;
在cpu继承模型中也是如此,amd和intel继承的cpu类中X86
字符串的地址是不相同的
这里因为内存对齐的问题,我们无法看清楚它的全貌。
但通过这里的继承模型,可以看出来在菱形继承问题中,不使用虚继承会造成两个CPU对象的多次继承,导致访问不明确的特性。
虚继承模型
那换成虚继承之后的模型是什么样子的呢?
先用d本身访问d._a
,可以看到红色箭头所指区域的内存被初始化为0
再指定作用域B::
进行访问,会发现其修改的依旧是这个地址的数据!
用作用域C::
来访问的结果也是如此,依旧修改的是相同内存位置的数据
由此可见,菱形继承了之后,_a
变量的地址就被确定为一个地址了。所有作用域中的_a
指向的都是这个公共地址,修改的都是这个公共地址的值,也就不会出现二义性问题!
最终运行完毕,内存窗口如图,A被丢到了最后面
再说回上方提到的cpu继承模型,进入调试窗口,可以看到这里分别分为了3个模块,保存了不同基类的成员。而它们之中的_Stucture
成员只有一个(指向"x86"
字符串的地址是相同的),所以就不会出现异义;
此时我们会发现,不管是上方ABCD的继承模型,还是这里的CPU继承模型,内存都出现了一定的空置
;那这里空着的空间是用来做什么的呢?也是内存对齐吗?非也!
虚基表
- 通过在虚基表中存放虚基类的偏移量,可以解决菱形继承产生的二义性问题。
下图能帮你了解这个虚继承模型中,内存的区块是怎么划分的;可以看到B和C这两个父类都会有一个虚基表的指针,指向虚基表的地址。地址中存放的是B和C对象跟A对象地址的偏移量。
B和C两个对象都有自己独立的虚基表地址,而不是共用一个,是为了方便切片时候的查询。
这样,虚基表就帮我们避免了在访问菱形继承模型时出现异义的问题。不过,因为多了一层间接的偏移量查询,访问公共基类的成员的效率会有所降低。
cpu的继承模型也是如此,在amd和intel这两个字符串存储位置上方,存放的就是一个虚基表的地址。而虚基表的这个地址之后紧跟着的就是一个当前对象跟基类对象的偏移量的数据;
同时也能总结出一个规律,虚基表的地址中的数据以全0开头,第二个(准确来说应该是偏移4个字节)的地址才是基类偏移量的数据
这样做就有一个好处,即便我们使用不同的基类指针(比如amd或者intel)来指向nuc的子类对象;
1 | NUC n1; |
这里的赋值需要对NUC
对象进行切片,要获取到AMD/INTEL
这两个父类的成员的同时,还需要获取到公共基类CPU
成员的位置;
此时因为存在虚基表,它们都可以通过各自虚基表里面存放的偏移量,来计算公共基类CPU成员的位置,从而获取到了CPU类的成员。
另外,当AMD和INTEL采用虚继承来继承CPU的时候,他们类内就已经会有虚基表了。跟他们自己是否存在子类无关!这样是为了保证访问时候的统一性。比如如下代码
1 | NUC n; |
对于编译器而言,其并不知道AMD*
指针到底指向的是本类还是子类,而AMD对象本身也有虚基表,就能保证不管是本类还是子类,都能通过同样的方式(通过虚基表查询偏移量)来找到虚继承的父类CPU的地址,从而访问到父类对象成员。
使用虚基表还可以让开发者灵活控制编译器对内存区块划分的优化。比如上面的两个栗子中,在VS2019里面,公共基类一般都是处于最下方的。
但如果我想设计公共基类放在最上方,也可以通过虚基表中的偏移量来实现。
而如果cpp强制规定公共基类必须要在普通基类的下方,而不使用虚基表来存放基类偏移量,那就限制了编译器的开发,也不方便实际的查找
C++STD中的IO流就使用了菱形继承来进行设计。
但对于我们而言,由于菱形继承实在过于复杂,一般不建议你这么“作死”;
1.9 继承和组合
- 继承:上述所说。每一个派生类对象都是一个基类对象
is-a
- 组合:在一个类里面包含另外一个类的对象成员。每一个B对象中都包含了一个A
has-a
;比如我们在自己的类中使用std::string
,此时我们自己的类和std::string
的关系就是组合
组合是黑盒复用,继承是百盒复用(子类能知道父类的细节,称为白盒)
实际情况中,建议优先选择组合,而不是继承。
- 继承增加了代码的复用性,但是在一定程度上破坏了基类的封装性。派生类和基类的关联很强,耦合度高。
- 对象组合是另外一种复用的选择,这时候,对象A的内部结构是不得而知的。这样就减小了对象之间的关联性,耦合度低,保护了封装,更方便代码的维护
不过,继承还有另外一种用途,那就是多态。我们下边会讲解的!
在软件设计中,追求高内聚,低耦合
,不同模块之间的关联度应该竟可能的低。在设计类间关系,和不同功能模块的时候,需要考虑具体场景来进行继承和组合的选用。
比如A继承B,此时两个类就被强关联在一起了,耦合度相对较高。对父类A的任何修改i,都会影响达到B,甚至导致B无法正常运行。
总结
多继承所导致的菱形继承问题,在一定程度上让C++的语法变得复杂了。比如java是没有多继承的。在实际使用情况中,不建议使用多继承。
2.多态
- 静态多态:函数重载
- 动态多态:派生类和虚函数组成的多态
多态通俗地讲就是多种形态,当不同的对象去完成相同的事情的时候,会产生不同的状态。
比如买票这个行为,会衍生出全票、儿童票、学生票等等类型。不同身份的人过来买票,应该调用不同的处理流程。使用多态,就能将这些不同流程的相同类型函数(都是在买票)给拟合成不同子类对象中的同名函数;
注意,多态只是实现这个场景的方式之一;你当然可以封装毫无相干的类,或者是使用函数重载,多个函数,判断语句来解决此类问题。
2.1 虚函数
2.1.1 基本使用以及动态多态
虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。
这里顺便提一嘴函数的三种关系:重载、隐藏(继承中同名问题)、覆盖(多态中虚函数被子类覆盖)
虚函数需要满足两个条件
- 函数名、参数、返回值都相同
- 父类中该函数使用了
virtual
关键字来修饰此函数
而调用的时候,必须是父类指针/引用
指向子类的对象的时候,才会调用子类重写后的虚函数(如果没有重写该函数,则调用的依旧是父类的函数)
以下面这个动物说话的代码为例
1 |
|
当基类Animal中的Talk函数没有用virtual
修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数
当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。
对于虚函数,有几点需要注意:
- 当基类的指针或引用指向派生类的对象时,就会触发动态多态,派生类中的同名函数会覆写基类中的虚函数
- 不能定义静态虚函数——因为静态函数是属于整个类的,而不是属于某一个对象
- 不能定义虚构造函数——总不能用派生类的构造来覆写基类的构造吧?这不符合继承中对构造函数的要求
- 析构函数可以是虚函数
2.1.2 虚析构函数
有的时候,我们需要析构一个子类对象时,往往会给基类的析构函数加上virtual
修饰,这样只要传派生类的对象给基类的指针/引用,就可以直接调用派生类对应的析构函数,完成不同的析构操作。
而不是都呆呆的调用基类的析构函数——那样就会产生内存泄漏,因为子类部分的成员并没有被析构!
这也是为何,类中析构函数会被统一重命名为destructor()
,便是为了让父类和子类的析构函数在设置了virtual
关键字后,函数同名,可以构成多态!
所以,如果一个类是基类,最好将析构设置成虚析构。
测试
1 |
|
其中我们将子类MyStack
的指针赋值给了父类。运行这个函数,会发现父类的析构函数被正常调用了两次,但子类的析构函数并没有被调用。
这就导致子类对象中的int *_a1;
指针申请的内存没有被正常释放,从而导致内存泄露;
1 | virtual ~Queue() |
当我们给父类的析构添加上virtual
关键字后,再次运行这个代码
1 | ~Queue |
此时父类和子类的析构都被成功调用了!
为了更好的观察析构顺序,给两个类都新增了一个成员变量作为标记位,在析构的时候打印。
1 | class Queue |
运行结果如下,可以看到,第二个指针q2
被delete
释放的时候,先调用了子类的析构函数,后调用了父类的析构函数。
1 | ~Queue 1 |
这样就不会出现内存泄露了!
2.1.3 子类不重写
在这个继承模型中,子类Stu
并没有重写父类函数,运行的时候,调用的都是父类的成员函数。这是一个普通的继承调用。
1 | class Person |
输出结果
1 | virtual A* f() |
2.1.4 协变
虚函数重写的时候,对返回值还会有一个例外的要求:协变;
前面提到,虚函数构成重写,必须要保证返回值相同。但协变的存在就新增了一个规定,我们的返回值并不一定要严格相同。
父类甲中函数返回值是某个父类乙的指针/引用时,子类丙虚函数重写的时候,返回值可以是子类丁/父类乙的指针/引用(对应父子关系即可,在这里,甲丙/乙丁
是两对父子)
1 | // B类继承了A类 |
上面的代码中,Stu
子类对父类虚函数的重写,返回值就是子类的指针;编译通过并运行,结果如下。可见的确构成了多态。
1 | $ g++ test.cpp -o test |
如果带上引用,效果也是一样的
1 | class Person |
这里我给func_a
设计了两个参数,保证两个函数参数相同;需要注意子类的引用没办法赋值父类的对象。只有父类的引用才能赋值子类对象。(权限只能缩小不能扩大)
1 | virtual A* f() |
下面的这种情况就是不允许的!两个函数的参数不同,虽然满足协变的条件,但不满足虚函数重写的规定;可以看到运行后,两次调用都是父类的func_a
函数;
2.1.5 重写不带virtual
子类重写该函数的时候,可以不带virtual
关键字。即便不带,依旧保有虚函数特性,可以被二次重写。这是因为子类继承父类的时候,先继承了虚函数的声明(相当于从父类中继承了virtual
关键字)
记住这点,后面要考
虽然这个关键字可以被省略,但不建议你省略它。这个关键字能告诉其他开发者,这个函数是一个重写了父类的虚函数(也有可能是一个即将被重写的虚函数)。相当于一个提示。
1 | class Person |
输入结果如下
1 | virtual A* f() |
截图说明
坑人的问题
这个知识点就可以引伸出一个比较坑人的问题了
1 | class Dad{ |
请问如上代码的输出结果是什么?它调用的到底是谁的func函数呢?打印的a的值又是多少呢?
1 | A Dad -> 3 |
答案揭晓,选择的是D
,输出结果是Son -> 3
1 | $ ./test |
刚开始遇到这道题的时候,我也是一脸蒙蔽。直到看了题解才知道这里多坑人。
其中E和F肯定是不能选的,一般情况下这两个选项都是过来迷惑你的。
比如有人可能会觉得
new
了之后没有delete
,有语法错误!但实际上你不delete
编译器是不会报错的,要不然也不会存在因为忘记delete
而出现的内存泄露问题了。
回到 2.1.5小点 的开头, 提到了子类继承父类函数的时候,会先继承父类函数的声明;
对于普通函数而言,声明无伤大雅。但这里,子类和父类函数声明中参数a的缺省值不相同!
最终我们通过子类对象调用test()
函数的时候,是将子类对象的指针交给了父类对象的指针。不要忘记了,类中所有成员函数都会有一个隐藏的this
指针传参!
实际上,test函数的声明应该是下面这个。我们用子类对象掉用的时候,传入的this
指针是子类对象的指针,自然就出现了将子类对象赋值给父类指针
的情况。
1 | virtual void test(Dad* this) { |
此时就满足了虚函数的两个条件:父类指针指向子类对象;子类重写了父类的虚函数。
这时候调用的func()
函数,自然是子类中被重写了的func()
函数,但由于继承了父类的函数声明,a的缺省值被修改成了父类中func()
函数的3,最终就打印出了 Son -> 3
的结果;
为了验证这个结论,我们还可以把子类中func
函数的缺省值删除
1 | class Dad{ |
理论上来说,子类函数重写了父类的func
,此时这个函数没有缺省值,调用一个没有传参的func()
函数应该是会报错的。
但由于其继承了父类中的函数声明,并没有报错,编译通过了,输出的结果不变
1 | $ g++ test.cpp -o test |
所以啊,为了避免这种情况,虚函数请不要设计缺省值!
还是上面那道题,如果是直接调用func,应该输出什么?
1 | class Dad{ |
这时候就和什么继承父类函数声明没有关系了,直接调用的就是子类自己重写了的函数,可以理解为是一个普通的函数调用
1 | $ ./test |
多态必须要父类指针/引用指向子类的时候才能触发!
1 | int main() |
1 | $ ./test |
2.2 C++11 override和final
C++11中新增了override和final这两个关键字
2.2.1 final
final用于类内成员函数之后,作用是让这个虚函数无法被重写
1 | virtual void Func1() final |
这个关键字的第二个做用,修饰类,被修饰后的类无法被继承
1 | // C++11直接用关键字final修饰,B类就不能被继承了 |
2.2.2 override
该关键字用于子类中,也是丢在函数后,用于验证是否完成重写
1 | class A |
比如在上面的代码中,基类中并没有test2存在,此时我们在test2后加上了override
,编译器就会进行检查并报错。因为test2并不是一个对基类中函数的重写
将override添加到test1
函数之后,就不会报错了。
1 | class A |
但如果将基类A的test1的虚函数virtual
属性去掉,则又会报错;
如果基类和子类两个同名函数的参数不相同,不构成重写,也会报错
这个关键字就可以用于在多态类设计中,比如所有子类都会有一个buy
的函数重写,那就可以在buy函数后添加一个override
,来检查我的重写是否完成,参数是否与基类中该函数的参数相同,以及函数名是否正确。
2.3 重载、覆盖(重写)、隐藏(重定义)的对比
常考,要理解并记忆
3.抽象类
包含纯虚函数的类就是抽象类,抽象类不能实例化对象
3.1 纯虚函数
在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。
纯虚函数的基本形式如下
1 | //virtual 函数返回类型 函数名()=0; |
派生类中,必须重写基类的纯虚函数,否则该类也是抽象类
1 | class A { |
当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数
和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数
纯虚函数内部是可以写函数实现的,但是没有任何意义。因为纯虚函数必须要被子类重写,这个纯虚函数本身是不能被调用的。
3.2 抽象类
包含纯虚函数的类就是抽象类,抽象类有下面几个特点:
- 抽象类无法实例化对象;
- 抽象类的派生类必须重写基类的纯虚函数,不然派生类也是抽象类;
- 如果在基类中定义的纯虚函数是const修饰的,则派生类中对应的函数也需要用const修饰;
如果我们在子类里面修改了函数的参数,那就不构成重写;此时子类B也是抽象类,无法被实例化对象了
1 | class A { |
4.几个重要概念
4.1 实现继承和接口继承
普通函数的进程是一种实现继承
,派生类继承了基类的函数,可以使用这个函数。此时继承的就是函数的实现;
多态中的虚函数是一种接口继承
,子类继承的是父类中虚函数的接口,目的是为了在子类中进行重写,以达成多态的目的。此时继承的是函数的接口。
所以,如果不是为了多态,那就不要把父类的函数定义成虚函数。
4.2 动态绑定和静态绑定
这是两个和编译相关的概念。
- 静态绑定又称前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态(函数重载)
- 动态绑定又称为后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型来决定程序的行为,调用具体的函数,又称为动态多态。
4.3 父类的构造和析构中虚函数不生效
请记住,在父类的构造和析构中,如果出现虚函数,则只会调用父类自己的函数实现,子类针对该虚函数的重写不会生效!
以下面的代码为例
1 | class Base { |
可以看到,子类构造的时候,会先调用父类的构造函数,在父类的构造函数中调用func,是父类的func(即便func是虚函数)。
1 | Base begin |
具体请参考我的另外一篇关于一道CPP选择题目的博客【点我】
5.包含虚函数的类的大小
请问下面的代码中,b和d对象的大小分别是什么?
1 | class Base |
结果如下,b的大小是8,d的大小是12
当我们使用了virtual
关键字修饰函数之后,类中就会出现一个虚函数表,简称虚表(需要和虚基表区分开来)
后文将解释虚函数表的作用,只有虚函数才会存在于虚表中
这个虚函数表是一个指针_vfptr
,指针的大小是4/8
字节,b类的大小由虚函数表指针和int组成,d类的大小由虚函数表指针和两个int组成。
- 在32位下,这两个类的大小分别是8和12;
- 在64位下,这两个类的大小分别是16和24(除了指针是8字节外,还需要内存对齐);
当我们把Base类中的函数修改回普通函数,可以看到类的大小又变成只包含一个int的4字节了。而Dervie类由于依旧有virtual
的存在,所以大小不变。
6.虚函数表(虚表)
以这个类为示例,让我们来看看虚表的样子
1 | class Base |
在内存窗口中,可以看到这两个对象的基本模块。子类对象中也存在一个虚表,而且可以发现,父子类的虚表中,只有func1
的函数地址是不同的。
这里的_vfptr
是virtual func pointer
的缩写,中文名是虚函数表指针,可以简称为虚表指针
一定要区分虚函数表(多态)和虚基表(菱形继承)!
6.1 虚函数重写和覆盖的概念区别
这里就需要提及重写和覆盖这两个概念的区别了
- 虚函数重写:语法层的概念,指子类中重写父类中虚函数的函数实现
- 虚函数覆盖:原理层的概念,子类对象的序表中,子类拷贝了父类的虚表,重写后的函数的函数指针覆盖了基类对应虚函数的指针
多态的实现,就依赖于子类虚表中对函数指针的覆盖,运行时,去指定对象的虚表中,调用对应的函数指针。这是一种运行时决议
调用方法的操作;
在VS的调试窗口中,我们能看到一个完整的父类Base对象,这也是父类指针指向子类对象的实现原理。此时父类的指针是完全没有办法知道自己指向的是父类对象,还是某个子类对象;
虚函数表的存在,帮我们实现了通过相同的函数调用方法,实际却触发了不同函数的流程的操作。
6.1.1 运行时决议和编译时决议
- 多态调用,运行时决议:运行到这里时确定调用函数的地址
- 普通调用,编译时决议:在编译时就确定调用的函数的地址
- 因为存在一层通过虚函数表的跳转,所以多态调用会比普通调用的速度慢一些。
依旧是上方的两个类,在基类和子类中同时存在一个普通函数Func3()
,此时通过父类指针去调用的时候,就会发现二者调用的都是父类的Func3
。
这正是因为非虚函数是没有进入虚函数表,此时对Func3
的调用就是一个普通函数调用;此时Func3
函数的地址在编译出可执行文件的时候,就已经被确定为了基类中的函数地址。
而Func1
因为是虚函数,存在于虚函数表中,所以是通过运行时查询这个虚函数表,来找到父子类不同的函数地址,最终实现多态调用。
6.1.2 看看汇编
从图中可以看到,对于Func1
的调用,最终是从虚函数表中提取出来的地址,call eax
寄存器中的地址,这便体现了运行时决议;
而对Func3
的调用,是编译时决议,直接已经确定了的基类中该函数的地址,直接call 09511CCh
这个函数地址来调用函数了。
对于指向基类对象的调用也是这样
这里就能很直观的看到,多态中虚函数表,让父类指针不管是指向子类对象、还是指向父类对象,都能通过相同的汇编指令来调用正确的函数。
6.2 子类对象赋值给父类为何无法实现多态?
我们都知道,继承了之后,如果把子类对象赋值给父类,则会产生切片。此时无法构成多态。
这是为什么呢?
因为编译器在编译的时候,就已经确定了这些函数的地址。
- 编译器检查是否符合多态的语法
- 不符合多态的语法,则直接确定对类函数调用的成员函数地址
- 符合多态的语法,那就编译出运行时决议的汇编语句
此时地址就已经确定了,根本不存在从虚函数表中找函数地址的步骤,自然就不能实现多态调用了。
这时候可能有些人就会有个不成熟的想法:如果将子类对象赋值给父类对象,切片的时候把子类对象的虚表指针也复制到父类中,那不就能实现多态了吗?
不行!
1 | Derive dd; |
以上面的代码为例,当我们把一个对象赋值给父类的指针时,程序运行的时候并不知道,这个指针指向的到底是父类还是子类对象。
假设我们在切片的时候,将子类对象dd的虚表指针也拷贝复制给父类了,那就会出现一个严重的问题:ptr1
在调用函数的时候,调用的也是子类的函数!
这不就乱套了吗?!
理论上bb是一个父类对象,赋值给Base*
指针后,我们调用函数的预期是调用父类的函数。但由于bb对象是从子类对象切片而来的,拷贝了子类的虚表指针,此时找到的也是子类的函数地址,不符合预期地调用了子类重写后的虚函数!
所以!为了避免这种不符合语法预期的问题,在切片的时候,只会将子类对象中的成员变量拷贝给父类,并不会拷贝虚表指针!切片生成的父类对象,虚表指针依旧是父类自己的虚表指针!
下图中可见,b3是切片而来的父类对象,其虚表指针以及虚表中的函数地址和Base b1
完全相同。一个类的虚表其实只有一张。
所以,对象并不能实现多态。即便理论上可行,但依旧不能这么做!
6.3 子类中新增虚函数,但监视窗不显示
6.3.1 实地探索
1 | class Base |
当我们在子类中新增了一个虚函数Func4
之后,再次打开监视,会发现子类的虚表中依旧只有两个函数指针。这是怎么回事?难道说子类没有被另外一个类继承,它的虚函数就不会进这里的虚表吗?
通过内存窗口,我们可以看到这里的出现了两个监视窗口中已有的函数地址,但后面还有一个和前面两个很接近,但在监视窗口中没有出现的地址。而在这个地址之后是一行全0(即nullptr
)
以nullptr
做结尾作为for循环的判断条件,我们可以把虚函数表中函数的地址都打印出来
1 | //重定义函数指针,需要将新的名字放在括号中间 |
运行结果如下, 可以看到成功打印出了3个函数的地址,和内存窗口中看到的数据一致
1 | typedef void(*V_FUNC)(); |
既然是函数指针,最终我们是可以通过函数指针来调用函数的。添加了函数调用部分的代码后,再运行,可以看到最后一个函数的确是子类中新增的虚函数Func4
所以,VS的监视窗口中不显示Func4
是因为VS认为这个函数没有被子类重写,无关痛痒,于是在监视窗口中隐藏了。
实际上,只要是虚函数,那就是会进入到这个类中的虚函数表里面的!
记住,只要是虚函数就一定会进虚表!
6.3.2 为什么不新增一个子类的虚表?
这里我还思考过另外一个问题,既然这个是子类自己的虚函数,那为什么没有多开一个虚表来存放这个函数的指针,而是直接放入到了继承自基类的虚函数表中呢?
1 | 下图是Derive在VS2019的内存分布模型 |
说明参考代码块中的注释。
6.4 虚表的存储位置
虚表是存在哪里的?先说答案:虚表是存在常量区里面的。
下图中的b1和b3是两个不同的Base
对象,但我们会发现它们的虚表地址包括函数指针的地址都完全相同。毕竟这是两个完全相同的类,虚表里面的内容确实是相同的。
这就告诉了我们,相同的类,其虚表在内存里面只有一张。初始化的时候,将这个类的虚表找到,并插入到类的对象中。
那么虚表是存在内存中的那个区域里面的呢?
首先排除栈和堆,栈是随时用随时开辟的,而堆需要动态内存管理,对于这种编译器自己完成的操作,也不应该是这样。而静态区/数据段
放的是全局数据或者静态变量,好像也不符合虚函数表不变的特性;相比之下,常量区/代码段
更靠谱。
有了猜想之后,就要来验证了。
我们将常用的存在内存中不同位置的数据类型都弄出来,分别打印它们的地址。
1 | int c = 2; |
输出结果如下
这时候可以发现,虚表的地址和常量区/代码段的地址开头相似,都是00DF9B
,说明它更加靠近代码段的区域。
而虚表的地址00DF9B34
是小于常量区/代码段的00DF9B6C
的,这就表明了在内存中,虚表的地址比这个常量区参数的地址更低。而在内存中,不同区域的分布如下,常量区就是在最低处的。
1 | 栈 |
实锤了,虚表就是存在常量区里面的!类的虚函数表是在编译阶段就已经生成了的。
6.5 多继承中的虚表
先说结论,如果出现了多继承,那么子类中会根据继承的父类分别产生独立的虚表(如果不是独立的,那就没有办法实现某个父类指针指向子类时,对子类的切片)
以下就是一个最简单的多继承
1 | class Base |
通过监视窗口,能看到这个对象的模型大概是如下图所示
其中能看到子类独有的虚函数Func4是存在第一张虚表里面的(VS监视窗口依旧没有显示出来)
这里还会发现一个问题:Base和Base2这两个基类中都有虚函数Func1
,那为什么子类中这两个类的虚表中,这两个被子类重写的Func1函数的地址不相同呢?
通过之前写的打印函数来打印第二章虚表里面的函数
1 | typedef void(*V_FUNC)(); |
这里需要注意的是,我们对d这个子类的指针+1的时候,会直接跳过sizeof(Derive)
个空间的大小。为了能精准地通过+sizeof(Base)
找到Base2
基类的虚表,就需要将子类的指针强转为char*
,这样每次+1就是移动一个字节的空间。
1 | PrintVfptrTable((V_FUNC*)(*((int*)((char*)&d+sizeof(Base))))); |
运行可以看到,即便内存不同,但实际上调用的依旧是子类的Func1
函数;也能看到子类单独新增的虚函数只会放在第一个虚表中。
6.5.1 Func1地址不同?
在上面VS打印的虚表中,会发现一个问题:Base和Base1父类中的两个Func1
函数的地址不相同,但最终我们看到的运行结果又都是子类重写后的Func1
把相同的代码挪到liunx环境下,编译运行,发现出现了段错误
1 | $ ./test |
顺带一提,在linux下直接编译本博客中的代码会出现如下警告,因为我们对指针进行了多次强转,不用管他
1
2
3
4
5
6
7
8 >g++ test.cpp -o test -std=c++11
>test.cpp: In function ‘int main()’:
>test.cpp:92:44: warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
PrintVfptrTable((V_FUNC *)(*((int *)&d)),3);
^
>test.cpp:93:69: warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
PrintVfptrTable((V_FUNC *)(*((int *)((char *)&d + sizeof(Base)))),1);
^
这是因为我们在打印虚函数表中,判断条件是当前函数指针为空,这是VS下对虚函数表的结束规定(以nullptr结尾),并不是linux下的操作,也不是C++对虚表的统一规定。
所以,为了能正常打印出虚函数表,我们需要将打印函数的判断条件改成固定值;因为我们已经知道了虚函数表中函数的个数了。
1 | // 重定义函数指针,需要将新的名字放在括号中间 |
再次编译运行,也出现了相同的结果,两个基类虚函数表中的Func1
地址不相同
1 | $ ./test |
再新增一个直接对Derive::Func1
函数本身地址的打印
1 | PrintVfptrTable((V_FUNC *)(*((int *)&d)),3); |
输出结果如下,可以看到这个函数本身的地址和第一张虚表里面的Derive::Func1()
地址是吻合的,但是和第二章虚表的地址不相符合
1 | _vfptr: 0x400cd0 |
而在windows的vs2019中,打印的地址就更奇怪了,其和两个虚表中的地址都对不上!
1 | _vfptr: 002D9B84 |
这里我还发现了一个奇怪的问题,相同的代码在windows下和linux下的效果不同
1
2
3
4 // 下面的代码在windows下可以正常打印函数地址,linux下打印出来的是0x1
printf("Derive::Func1 %p\n",(&Derive::Func1));
// 下面的函数在linux下可以正常打印函数地址,在windows下报错“强制类型转换失效”
printf("Derive::Func1 %p\n",(void*)&Derive::Func1);有人知道这是为啥吗?😂
你可以理解这是在不同平台下,对虚表中函数指针的一个处理,其最终还是会调用到正确的函数的。
在windows下查看反汇编,能看到其最终是调用了ebp-14h
的一个地址,在内存窗口中可以看到,这个地址正是虚表中存放的Func1函数地址
使用调试在反汇编窗口中逐条运行,能进到这个call [ebp-14h]
语句中,可以看到在002910B9
这个地址上存放的就是子函数中的Func1
函数地址,这里的汇编指令jmp
相当于跳转到这个函数地址上
再进一步观察会发现,这里显示的地址和打印出来的func1
函数的地址还是不相同
再跳转,还是不同
再次跳转,依旧是不同
再一次跳转,就跑到了子类中Func1
函数的执行流里面了。此时就开始执行这个函数了!
所以,这只是编译器在某些层面上的处理而已。包括第一个基类的虚表,也是这样的函数地址跳转。在linux下和windows的不同编译器下观察到的情况都不一样,我们没必要过多纠结于这里,只要知道有这类编译器处理的存在就可以了。
最终两个基类对func1
的函数调用的汇编流程如下图
这其中,我们要发现Base2对Func1的调用,主要是多了下面这两句非常不同的汇编
1 | 008426B0 83 E9 08 sub ecx,8 |
原本走到这一步,ecx寄存器的值是 0x00849b98
。这一步执行完毕后,ecx的值是0x00849b90
,可以看到更新后的值比原本的值少了8字节;正好是Base
类的大小!
这是因为在当前对象模型中,两个父类对象需要调用的Func1都是子类的Func1,此时使用的this
指针应该是子类Derive
的this指针,处于子类对象地址的起始位置。对于Base* ptr1
来说,其指向的地址本身就是子类的起始地址,所以不需要进行修正。
但Base2* ptr2
指向的位置并不是子类的起始地址,此时就需要-8
回到起始位置,用修正后的this指针来调用子类的Func1函数;
这也就能解释为什么两个虚表中存放的函数指针地址不相同,因为调用的流程不一样,Base2的指针在调用的时候需要对ecx寄存器中的this指针进行修正。
6.5.2 指针切片地址不同
当我们用不同的父类指针指向这个子类对象的时候,由于会发生不同位置的切片,最终的地址并不相同。这点我们通过对象模型也能看出来,不同的父类都需要指向自己的那部分,所以切片后的地址不同。
1 | Derive d; |
6.6 菱形虚拟继承中的虚函数表
1 | class A |
使用上图代码进行虚拟继承的时候,内存模型如图,在B类和C类中都会有一个继承自A类的虚表;因为这里没有进行函数重写,所以地址是一样的。
当我们在B和C类中重写此函数,对象模型如下,B类和C类中虚表的函数指针不同
但如果我们把B和C对A的继承都改成虚继承,此时就会报错了!
在前面的虚继承讲解中,提到了在VS下,是将公共基类放在子类的最后面的,此时模型的顺序大概如下
1 | B |
由于B和C都使用了虚继承,解决了数据二义性问题,但没有解决A中的虚表到底是存B重写后的func,还是存C重写后的func的问题;
这个时候我们就需要在D里面重写func,这时候就能确定最终使用的是D里面对func的重写,也就不会有到底是选B还是选C的分歧问题了。
6.7 虚函数和inline
6.7.1 状态观察
我们先尝试给一个虚函数加上inline
内联关键字
1 | class A |
修改VS2019项目的属性
转到反汇编,可以看到f1函数被展开,f2函数依旧是call地址的调用
1 | class A |
如果不将声明和定义分离,可以观察到两个函数都被编译器认作是内联而展开了。
此时新增一个继承,再来看看反汇编
1 | class A |
此时我们发现,似乎f1函数依旧是有内联的属性
这说明虚函数是可以用virtual
关键字来修饰的。
但如果用多态调用呢?
1 | A* aa = new B(); |
此时就能发现,原本的多态展开,就变回了call函数地址的调用
这是因为内联函数是没有地址的!而多态基于虚基表实现,虚基表中必须要存放函数的地址!
6.7.2 结论
结论就是,在多态中,对虚函数的inline
修饰不会报错,但会被编译器忽略(不会有内联的属性),依旧是个普通的函数
6.8 派生类的初始化过程中,调用完毕父类构造函数后,调用子类构造函数之前,虚表是什么样子的?
这是一道面试的考题,来自牛客网。
问:C++八股问派生类的实例化过程中,调用完基类的构造函数之后,调用派生类构造函数之前,虚函数指针是怎么样的?
使用如下代码来做个调试测试吧。
1 |
|
开始调试,执行构造函数之前,地址内都是随机值。
随后F11进入Derive类的构造函数,再F11会进入父类Base的构造函数
再次F11,会开始执行Base的初始化列表构造,此时虚函数表已经被赋予地址了,从调试窗口可以看出来,这是父类的虚函数表。
再次F11,执行Derive类的初始化列表之前,虚函数表依旧是父类的。
再次F11,开始执行Derive的初始化列表,此时虚表发生了变化,变成了子类的虚函数表了。
所以这道题目的答案已经明了了:子类的构造函数初始化列表执行之前,虚函数表的指针是父类的虚函数指针。
7.静态成员函数不能是虚函数
静态成员函数属于整个类,无法被指定对象重写。
而且静态成员函数没有this指针,可以直接用类名来调用,但这也决定了其无法访问到虚表,自然也无法实现多态。
所以静态成员函数是不能做虚函数的,在VS中这样写会直接报错
8.构造函数不能是虚函数
通过调试可以发现,虚函数表中的指针原本是随机值,是在构造函数中被初始化为正确的函数地址的
既然是在构造函数中初始化的,那么虚函数表就不能先于构造函数被初始化出来,也就没有办法通过虚表来实现多态。
所以构造函数是不能为虚函数的!
9.菱形继承构造顺序
如下虚菱形继承中,调用构造函数的顺序是什么?
1 | class A { |
我们只需要知道,类的对象在实例化的时候,初始化的顺序就是类声明的顺序
依照代码中的顺序流程读下来,就是构造函数被初始化的顺序;
而且A的构造函数也是由最终子类D直接发起的,而不是B或者C发起的。
下面这道题也是一个有关于构造顺序和多态调用的问题
在linux下测试,最终打印的是
1 | barfoob_bar |
调用顺序为
- 父类A的构造函数调用父类自己的bar函数,因为这时候虚表还没有初始化,所以不存在多态,打印
bar
- 父类指针指向子类,调用foo函数,由于foo函数不是虚函数,不在虚表内,所以调用的是父类的foo函数,打印
foo
- 父类指针调用bar函数,此函数为多态调用,调用的是子类的bar函数,打印
b_bar
所以最终的输出结果是barfoob_bar
The end
内容丰富的继承和多态的博客终于补充完毕了!
如果有问题还请提出!