本文将介绍C++的另一个基于继承的重要且复杂的机制,多态。
一、多态的概念
多态,就是多种形态,通俗来说就是不同的对象去完成某个行为,会产生不同的状态。
多态严格意义上分为静态多态与动态多态,我们平常说的多态一般指动态多态。(后文介绍的多态也是动态多态,只在本部分介绍一下静态多态)
1、静态多态
静态多态又称作静态绑定(早绑定、前期绑定),即在函数编译期间就决定了程序的行为(即函数名修饰规则,具体C/C++(二)中有详细描述)。
平常最经常用的静态多态就是函数重载。
2、动态多态
动态多态又称作后期绑定,在程序运行期间再根据具体拿到的类型来调用具体的函数,确认程序的具体行为。
我们平常说的多态一般指动态多态,静态动态一般就说函数重载。
二、多态(动态多态)
从技术方面来说,多态就是不同继承关系下的类对象,去调用同一函数(调用的函数必须是虚函数,后文会介绍),会产生不同行为。
1、多态的构成条件
1、调用的函数必须是虚函数,且派生类必须为基类的虚函数进行重写。
2、必须用父类的指针 / 引用来调用虚函数。
(为什么必须传父类的指针 / 引用?这里初步解释,后面会在原理部分详细解释——因为父子类的赋值兼容原则,子类可以切片赋值给父类,父类却不能赋值给子类,因为可能会缺成员)
(那又为什么必须传指针 / 引用?因为传对象的话,子类只会把父类的那一部分成员拷贝过去,但是不会拷贝虚函数表指针,就不能成功调用对应的虚函数了)
2、虚函数
被 virtual 修饰的类成员函数称为虚函数。
class Person { public: // 虚函数 virtual void BuyTicket() { cout << "买票-全价" << endl;} };
2.1 虚函数的重写(多态的条件之一)
如果派生类中存在与父类完全相同(函数名、函数返回值、函数参数都完全相同)的虚函数,就称作派生类的虚函数重写了父类的虚函数。
#include <iostream> using namespace std; class Person { public: virtual void BuyTicket() { cout << "全价购票" << endl; } }; class Student :public Person { public: /* 子类重写父类虚函数时,如果不加 virtual 关键字,虽然也可以构成重写(子类继承下来父类的虚函数,仍旧保持虚函数属性) 但是这种写法不规范,可读性较差,建议不要这么做 */ virtual void BuyTicket() { cout << "半价购票" << endl; } }; void Test(Person& p) { p.BuyTicket(); } int main() { Person p; Student s; Test(p); Test(s); return 0; }
2.2 多态的两个特殊情况
2.1.1 协变(基类与派生类的虚函数返回值类型不同的时候)
当派生类重写父类虚函数的时候,基类与派生类的虚函数的返回值类型可以不同,但是必须是父类 / 子类的指针或引用。
当派生类虚函数返回值是父类 / 子类的指针或引用时,称作协变。
2.2.2 析构函数的重写
如果基类的析构函数也是虚函数,这个时候只要派生类定义了析构函数,不论是否加了 virtual 关键字,都视作对基类的析构函数构成重写。
(虽然基类和派生类的析构函数名字不同,看似违背了虚函数的重写原则,实际上编译器会对析构函数的名称做特殊处理,在编译后,所有析构函数的名称都会统一处理成 destructor)
#include <iostream> using namespace std; class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: virtual~Student() { cout << "~Student()" << endl; } }; // 只有派生类Student的析构函数也定义了析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。 int main() { Person* p1 = new Person; Person* p2 = new Student; delete p1; delete p2; return 0; }
3、C++11检测虚函数是否重写的两个关键字
从上文的介绍可以看出,C++对虚函数的重写要求比较严格。在有些情况下(比如函数名、返回值字母写反写错),可能会无法构成重写,导致无法构成多态。
但是这种错误在编译期间是不会报出的,只有在程序运行时才会发现,与预期结果不符,这个时候才来debug,得不偿失。
因此C++11标准提供了两个帮助用户检测是否完成重写的关键字:final 和 override
3.1 final
final 修饰某个虚函数,则这个虚函数不能再被重写
3.2 override
override 修饰派生类虚函数,检查派生类的虚函数是否基类的某个虚函数的重写,如果不是(比如拼写错了),编译报错。
4、纯虚函数与抽象类
在虚函数的后面加上 =0 ,这样的虚函数称作纯虚函数。
包含纯虚函数的类叫做抽象类(又叫接口类,在某类不代表具体实体的时候可以使用;另一个意义是说明多态想在其多个子类中实现),抽象类不能实例化出对象。
继承抽象类的派生类也不能实例化出对象,只有当这个派生类对纯虚函数进行重写,这个派生类才能实例化出对象。
因此纯虚函数在某种程度上间接强制了派生类的重写,更体现了接口继承思想。
(接口继承与实现继承:
普通函数的继承是一种实现继承,继承的是函数的实现,目的是使用这个函数;
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写达成多态。)
三、多态的实现原理(重点)
1、代码引入
#include <iostream>
using namespace std;
class Test
{
public:
virtual void test()
{
cout << _num << endl;
}
private:
int _num = 1;
};
int main()
{
Test t;
printf("%d", sizeof(Test));
}
让我们猜猜,sizeof(Test) 应该是多少?
很多人可能会说,函数储存在代码段里,不算在类大小里面,那就应该是4字节(32位系统) / 8字节(64位系统)?
但实际上:
这是为什么?
通过内存窗口的观察我们可以看见,Test对象里面除了储存了_num 成员变量,还储存了一个叫做_vfptr的指针变量,而一切指针变量大小在32位系统下都是4字节,在64位系统下都是8字节。
这个_vfptr是什么?这个指针我们叫做虚函数表指针,指向虚函数表。(v代表virtual,f 代表function)
2、虚函数表
虚函数表的本质,是储存着一个类里面的所有虚函数地址的一个指针数组。(一般情况下这个数组最后会放一个nullptr作为虚函数表的终止标记。)(注意:不是储存着虚函数,是储存着虚函数的地址,虚函数还是储存在代码段里的)
我们给出一个多态的代码:
#include <iostream> using namespace std; 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 _b = 1; }; // 派生类Derive继承Base并重写Func1 class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; int main() { Base b; Derive d; return 0; }
调用一下监视窗口:
我们可以发现:
1、派生类对象 d 由两部分构成,继承自父类的成员,和自己的成员
2、派生类和父类都有一个虚函数表指针,指向各自的虚函数表,虚函数表里面储存着虚函数的地址。
3、派生类的虚函数表和父类的虚函数表不一样,由于Func1完成了重写,所以d的虚表
中存的是重写的Derive::Func1;派生类完成重写了的虚函数覆盖了原有的父类虚函数。
所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
4、派生类其实把父类的三个函数都继承了下来,但是由于Func3不是虚函数,所以并未放到虚函数表中。
派生类虚函数表的生成流程:
1、先把基类的虚函数表拷贝到自己的虚函数表中
2、如果派生类重写了某个虚函数,在虚函数表中用这个虚函数地址覆盖原父类的虚函数地址3、派生类如果自己增加了虚函数,按照在派生类中的声明次序依次放到派生类虚函数表的后3、多态的原理
3、多态的实现原理
还是直接上代码:
#include <iostream> using namespace std; class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "买票-半价" << endl; } }; void Func(Person& p) { p.BuyTicket(); } int main() { Person Mike; Func(Mike); Student Johnson; Func(Johnson); return 0; }
观察多态实现代码图:
观察红色箭头可以看到,p在指向mike对象时,p->BuyTicket从mike的虚表中找到的虚
函数是Person::BuyTicket。
观察蓝色箭头可以看到,p在指向johnson对象时,p->BuyTicket在johson的虚表中
找到的虚函数是Student::BuyTicket。
这样就实现出了不同类的对象去调用同一函数时,展现出不同的形态。
再看一下汇编代码:
// 与多态无关的汇编代码都已去除 void Func(Person* p) { ... p->BuyTicket(); // p中存的是mike对象的指针,将p移动到eax中 001940DE mov eax,dword ptr [p] // [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx 001940E1 mov edx,dword ptr [eax] // [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax 00B823EE mov eax,dword ptr [edx] // call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来 以后到对象的中取找的。 001940EA call eax 00头1940EC cmp esi,esp } int main() { ... // 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调 用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址 mike.BuyTicket(); 00195182 lea ecx,[mike] 00195185 call Person::BuyTicket (01914F6h) ... }
就可以明白,满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的,因此叫做动态多态。
4、多态是如何实现的?(一句话总结)
首先,多态是一种基于继承和虚函数实现的机制,用来调用虚函数的函数必须传父类的指针或引用;然后,基类和派生类各有一张虚函数表,通过传参的不同(父类直接传,子类切片),对象内部的虚函数表指针会去各自的虚函数表里面寻找对应的虚函数地址,从而实现调用同名函数时产生不同的行为,达到多态的效果。
5、有关多态的一些小问题:
如果子类不重写虚函数,父子类的虚函数表一样吗?
储存的虚函数的地址是一样的,但是虚函数表毕竟是两张表,储存虚函数表的地方不一样,是分开存储的!
如果有许多同类对象,它们的虚函数表一样吗?
一样!同类对象共用一张虚函数表!
也就是说,虚函数表本质其实是个静态常量,被所有同类对象共享!