什么是多态?
多态是C++面向对象编程中的一个核心概念,它允许程序在执行过程中,根据对象的实际类型来调用适当的函数。多态性主要通过继承和虚函数来实现,这使得代码更加灵活和可扩展。多态的条件如下:1、调用函数是重写的虚函数。2、基类指针或者引用。
虚函数的概念
被virtual修饰的成员函数就是虚函数。
虚函数的重写
虚函数重写是指派生类重新定义(或称为覆盖)了基类中的虚函数。当派生类中存在一个与基类中虚函数具有三同,即相同名称、参数列表和 返回值(在C++11及以后版本中,还包括const属性和volatile属性)的函数时,该函数就重写了基类中的虚函数。
下面通过一个代码样例来简单看一看
在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样写。
协变是虚函数重写的一个特例。协变是指派生类重写虚函数时,与基类虚函数返回值类型不同。但是基类虚函数返回值类型是基类的指针或引用。派生类虚函数的返回值类型是派生类的指针或引用。
析构函数的重写也是一个特殊的例子。如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
为什么需要重写虚函数呢?通过下面场景便可以明白。
可以看到在释放Student的切片时,编译器没有去调用派生类的析构函数释放派生类对象部分,这导致了内存泄露问题。因为delete底层是会去让p对象去调用它的析构函数,然后调用operator delete来释放空间。在这个场景中用户期望调用析构函数的行为是一个多态调用。所以,我们需要重写析构函数以达到正常释放派生类对象的目的。
C++11标准提供了两个关键字,override 和 final。可以用于帮助用户检测重写情况。
final:修饰虚函数,表示该虚函数不能再被重写。
final修饰的类不能被继承。
想让一个类不能被继承不仅仅可以用final修饰这个类,还可以通过私有构造函数来实现。不过私有构造函数后需要对外提供一个静态函数以实例化类对象。私有化析构函数也可以做到让类不能被继承。但是需要提供一个对外的清理资源的接口以供用户释放资源。下面以私有化构造函数为例。
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
多态的原理
虚函数表
在C++中,虚函数表是一种用于支持多态性的机制。当一个类中含有虚函数时,编译器会为该类生成一个虚函数表,这个表存储了该类的虚函数在代码段中的地址。在运行时,通过虚函数表,程序能够正确地调用指向派生类对象的基类指针或引用所调用的虚函数。下面通过样例简单看一看。
sizeof(Base)的值是多少呢?答案是8,因为Func1是一个虚函数,编译器会生成一个虚函数表来保存虚函数的地址。_b为一个字节,虚函数表占四个字节。根据内存对齐的原则,所以sizeof(Base)为8字节。
多态的原理
先通过调试窗口简单看一看基类和派生类究竟干了些啥。
通过调试窗口可以发现派生类对象Johnson也有一份虚函数表。并且虚函数表的内容与基类的内容不一样。这是因为派生类的虚函数完成了重写,将原本基类的虚函数进行了覆盖。所以虚函数的重写也称为覆盖。重写通常是语言层面的叫法,覆盖是底层实现层面的叫法。
虚函数表本质是一个存虚函数指针的指针数组,在VS平台下,一般情况这个数组最后面放了一个nullptr。g++平台不会这样处理。
派生类的虚表生成:首先,将基类中的虚表内容拷贝一份到派生类虚表中。其次,如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚函数表中基类的虚函数。最后,派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
vftptr也许会因为成员变量被定义在栈区或事堆区上,而存储在栈区或堆区上,但是vftptr所指向的虚函数表是存储在代码段上的。在VS平台下,虚函数存储和普通函数一样存储在代码段中。虚函数表也是存储在代码段中。需要注意的是虚函数表存储的不是虚函数,而是虚函数指针,即虚函数在代码段(常量区)中的地址。g++平台下虚函数表和虚函数都是存放代码段(常量区)中。
下图的Func函数如何做到多态调用呢?如果传递的是基类对象,它会直接在运行时通过基类的虚函数表找到对应的虚函数进行调用。派生类也是同理。而普通调用则是在编译时,编译器确定地址调用。
下面通过反汇编简单看一看多态调用与普通调用的区别。
通过上图可以看到,无论传递的是基类对象还是派生类对象,多态调用的指令都是一样的。基类多态调用,运行时编译器会去基类的虚函数表中找到虚函数地址,然后调用。而派生类传递的是基类的切片(切割),所以派生类中基类的部分,虚函数表会进行重写。编译器会去重写后的虚函数表调用派生类的虚函数。
动态绑定与静态绑定
静态绑定指的是程序在编译阶段就确定了程序的动作。这也称之为前期绑定。一般函数重载就是一种静态的多态。
动态绑定又称为后期绑定,指的是程序在运行后才能确定程序的动作。虚函数的重写就是一种动态绑定的行为。
关于继承多态的试题
下面做一个题来提升一下对于多态的理解。
看代码依次来进行分析,首先,主函数中new了一个B对象。然后通过这个对象调用test函数。test()函数一定是由this指针进行调用,所以可以理解成A* ->func()。但是func是一个重写的虚函数。所以应该调用的是B ::func()。这里最具迷惑性的坑来了,就是基类和派生类的func函数都给了默认参数。而这个默认参数是取基类的默认参数。因为虚函数的重写的是实现。所以这里的返回值、函数名、参数列表部分都是用的基类。最终输出的结果是 B->1 。
为什么基类对象不能构成多态呢?
若出现派生类对象赋值给基类对象切片时,并不会拷贝虚函数表。这是因为若基类对象构成多态势必拷贝虚函数表,那基类对象调用虚函数时,就会调用派生类的虚函数。那就乱套了。
基类对象不能构成多态是因为方法调用在编译时就已经确定,且不会通过虚函数表进行动态查找,所以无法调用派生类中的覆盖方法。要实现多态,必须使用基类指针或引用指向派生类对象。
打印虚函数表的程序
简单说明一下,上图的情况,基类Person有一个虚函数BuyTicket,两个成员函数Func1和Func2。派生类Student重写了BuyTicket()和一个虚函数Func3。通过监视窗口可以看到,Student对象的虚函数表没有Func3。下面,写一个程序来验证一下我们的猜想。
这里利用了VS平台的虚函数表的结尾时nullptr的特性对虚函数表进行了打印遍历。通过程序可以发现,Func3就是紧跟在Student类对象的虚函数表后面的。
关于多继承多态的问题
这里以下面的多继承为例。
这里有两个基类分别是Base1和Base2,分别有一个整型成员变量和两个虚函数func1和func2,有一个派生类Derive继承了Base1和Base2,也有一个整型成员变量,和一个重写的func1和虚函数func3。下面简单来看一下Derive定义的对象模型。
所以Derive对象的大小是20字节。下面通过一段代码,看看VS平台对于多继承的派生类的多态调用是如何进行处理的。
乍一看好像没有什么特别的,但是实际在底层汇编处理时,别有洞天。下面通过监视窗口简单瞅一瞅。
通过监视窗口可以看到,Base1的虚函数表中func1的地址和Base2中的func1的地址不一样。那为什么上面程序的结果是Base1对象的切片和Base2对象的切片调用的是同一个func1呢?下面通过反汇编一探究竟。
通过反汇编可以清晰的看到,VS平台编译器对Base2的虚函数表做了特殊处理,让派生类继承Base2的成员部分的虚函数表存的是对于this偏移量的处理的地址。本质上也是和Base1和d对象同一个func1的地址。至于为什么要这样处理?我推测根对象模型有关。由于先继承的基类会在后继承的基类的上面。而VS平台下,这里的Base1的虚函数表刚好就是在d对象的前四个字节。而Base2虚标则存储在d对象中的第8-11个字节的位置上。编译器会先去Base2虚函数表地址中存储的this指针中 - 8的位置找func1。而Base1和d的虚函数表恰好处于首地址。所以不需要想Base2那样处理。
抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
接口继承和实现继承
普通的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。**虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。**所以如果不实现多态,不要把函数定义成虚函数。这样会浪费资源。
inline函数可以是虚函数吗?
答案是可以,但是,在VS平台下,虚函数用inline修饰,这时候编译器会忽略inline属性,那这个函数就不再是内联函数。因为虚函数要存放在虚函数表中。
静态成员函数可以是虚函数吗?
答案是不行。有以下几种原因。
从对象与虚函数表层面看,静态成员没有this指针。它不与类对象关联,只与类关联。因此它不能被存放在虚函数表中。
从多态性和动态绑定层面上看,静态成员函数,在编译时即确定函数地址。这与虚函数在执行时确认函数地址来说是相违背的。因此静态成员函数与动态绑定即只相违背。