1. 多态的概念
多态 (polymorphism) 的概念:通俗来说,就是多种形态。多态分为编译时多态 (静态多态) 和运行时多态 (动态多态),这里我们重点讲运行时多态。
编译时多态 (静态多态) 主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的:

就比如这样就是一种编译时多态,对于两个不同类型的数据,都调用同名的流插入函数,然后流插入函数再根据各自的类型去适配。那么同一个函数名就表现出了多种形态:可以打印int类型的数据,也可以打印double的数据。
运行时多态,具体点就是去完成某个行为 (函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票 (5 折或 75 折);军人买票时是优先买票。再比如:动物叫的一个行为 (函数),传猫对象过去,就是" 喵喵 ",传狗对象过去,就是 " 汪汪 "。
2. 多态的定义及实现
2.1 多态的构成条件
要实现多态效果,第一必须是基类的指针或引用 ,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写 / 覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
2.2 虚函数
首先,所谓的虚函数和我们前面在继承当中提到的虚继承是两个概念,虽然用的关键字都是 virtual ,但虚继承是为了解决数据冗余和二义性的问题,而虚函数只是用来解决 "同一行为不同实现" 的问题。**虚函数的定义是:类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。**注意非成员函数不能加virtual修饰。
下图中就构成多态:

这里可以看到,虽然都是由Person类型的引用people去调用的BuyTicket函数,但到底调用的是哪个函数,跟people本身没没有关系,而是传给people的对象指定的。
2.3 虚函数的重写 / 覆盖
在上面讲解多态的构成条件当中提到了虚函数的重写 / 覆盖,其定义是:派生类中有一个跟基类完全相同的虚函数 ,即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同,称派生类的虚函数重写了基类的虚函数。
虚函数重写时,默认参数是 "静态绑定"(编译期由指针 / 引用的类型决定),函数体是 "动态绑定"(运行期由对象类型决定)。
要注意的是:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写 ,因为继承后,基类的虚函数被继承下来了,在派生类依旧保持虚函数属性,但是该写法不是很规范,不建议这样使用。不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
就像这样:
在 Student 类当中,没有用 virtual 修饰 BuyTicket 函数,但 Student 中的 BuyTicket 依然构成虚函数属性,所以依然构成多态,在调用函数时可以调用到对应的函数。
然后对于多态场景,此处虚函数的逻辑大概就是:调用基类中的virtual BuyTicket()这个函数声明,使用的是派生类中的cout << "买票打折" << endl ; 的函数实现。也就是我们上面说的:默认参数是 "静态绑定"(编译期由指针 / 引用的类型决定),函数体是 "动态绑定"(运行期由对象类型决定)。
所以这里就会有这样一个现象:

尽管我将Student类里面的函数设置为私有,但打印结果依然正确,这就是多态其实走的是上述逻辑。这其实也是C++的一个缺陷。
2.4 多态场景和多态效果
多态场景:满足 "虚函数 + 指针 / 引用调用" 这两个基础条件,是 "具备触发多态的环境"。
多态效果:在多态场景下,被调用的虚函数被派生类重写,最终执行派生类的实现,是 "实际体现多态的结果"。
简单说:有 "多态场景"≠ 一定有 "多态效果" ------ 只有虚函数被重写时,才会体现 "运行时绑定" 的多态效果;若虚函数未被重写,仍执行基类的实现,但调用逻辑仍属于多态场景。
2.5 多态场景的一个问题
既然说到这里,那大家来看这样一个题目:

首先是一个B类型的指针 p 指向的是B类型的空间,然后用 p 去调用 test 函数。我们第一个要明确的点是,在此时,并不构成多态效果,只构成多态场景。
test()是基类 A 的虚函数, p是B*类型,调用形式是:指针调用虚函数 ------ 这已经满足 "多态场景" 的基础条件。但之所以没有构成多态效果,是因为 B 类没有重写test()函数,所以编译器 / 运行时的逻辑是:
-
第一步(编译期):检查B类是否有test()的重写版本 → 没有;
-
第二步(运行时):通过B的虚函数表查找test() → 虚表中test()的地址指向基类 A
::test(); -
最终结果:执行 A
::test(),无多态效果,但调用流程仍走虚函数表,属于多态场景。
对于上述的虚函数表,大家暂时只要直到它是用来存储虚函数的函数地址的一个数组就行,具体的后续在多态的原理中会提到。
然后我们要理解在继承中的查找规则:编译器优先在派生类自身搜索成员,找不到再逐层向上到基类搜索。
因此在此处,p指针首先会在B类型当中寻找test函数,但是没有找到,所以再去基类当中找test函数,当 B* p 调用 test()时, test()的 this 指针类型是B*,因为p是B*,但因为 test()是从 A 继承的,所以this会被隐式转换为A * ,但本质还是指向B对象。然后调用test中的func函数。第二个问题就是,这里的func函数到底调用的基类的还是派生类的。
首先,既然this的类型是A*,就满足 " 通过基类指针 / 引用 调用虚函数 " 这个条件,并且因为已经有继承关系,所以即使派生类中的 func 函数没有用 virtual 修饰,但其函数的函数名、返回类型、参数类型,都和基类当中的 func 相同,因此也满足 " 派生类必须对基类的虚函数完成重写 / 覆盖 " 的条件。因此这里构成多态场景。
我们前面提到,如果构成多态场景,具体调用哪个函数,是由指向的对象决定。因为这里的指针p指向的是B类型的对象,因此,test()的 this 指针指向这个 B 对象,即使类型被隐式转换为 A*。那么根据我们上面提到的:默认参数是 "静态绑定"(编译期由指针 / 引用的类型决定),函数体是 "动态绑定"(运行期由对象类型决定)。
所以在此处的逻辑是:这里this是A*类型,因此func()的默认参数 val 绑定基类A的val = 1;而this实际指向B对象,因此函数体执行B::func()。
简单点来说就是:基类中的virtual void func(int val = 1) 加上派生类中的 std::cout << "B->" << val << std::endl; 因此最终结果就是:B->1,选择B。
那现在再出一个兄弟题目:

只修改一行代码,其余不变,那么此时答案就应该选:D. B -> 0。
这里的 func( ) 是虚函数的重写,所以属于多态场景,但因为我们直接用 B* 调用B:: func( ),运行时通过 B 对象的虚表指针找到 B 的虚表,取出 func( ) 的地址,就是B:: func( )。因为 B:: func( ) 有自己的默认参数 val = 0,所以执行B:: func( ) 时,val 用的是 0。
之前的 p->test( )是调用继承的基类虚函数,而这里 p->func( ) 是直接调用派生类自己的虚函数。虚函数的默认参数是静态绑定:如果是 A* 调用func( ) ,默认参数用 A::val = 1;如果是 B* 调用 func( ) ,默认参数用 B::val = 0。在这边是 B* 调用,故选择:D。
2.6 虚函数中的一些小问题
2.6.1 协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。用代码描述就是像这样:

要注意的是A和B这两个类,必须要是继承关系,否则就不能构成协变。
2.6.2 构造函数的重写
若基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。
这是因为:虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,所以基类的析构函数加了 virtual 修饰,派生类的析构函数就构成重写。
下面展示一个正常的代码:

因为我们在初始化的时候,是先初始化基类,再初始化派生类。析构的时候,就要将顺序反过来,先析构派生类,再析构基类。所以在这段代码当中,先去析构B类型的b对象,析构完b对象中的数据之后,再析构b对象中的基类成员。然后再析构基类对象。
但是在下面这个场景的时候,就会出现一些小问题:

上面的代码我们可以看到,如果~A (),不加 virtual,就不构成虚函数;如果不构成虚函数,就不会构成重写,那么 delete p2 时只会根据指向的内容,调用的 A 的析构函数,而不会调用 B 的析构函数,就会导致内存泄漏问题,因为~B () 中在释放资源。
由此可见,当通过基类指针 / 引用指向派生类对象时,若基类析构函数不是虚函数,delete 指针只会调用基类析构函数,不会调用派生类析构函数,导致派生类中动态分配的资源无法释放,引发内存泄漏。而将基类析构函数设为虚函数后,delete 基类指针时会通过虚函数表动态绑定,先调用派生类析构函数,再自动调用基类析构函数,确保所有资源都被正确释放。
2.7 oerride和final关键字
从上面可以看出,C++ 对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的。只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写。
用上面的例子继续解释:

注意override是用来修饰派生类成员的,此时基类中没有使用virtual关键字,不能构成虚函数重写的条件,所以此处override关键词修饰后,就会报错:"B::~B": 包含重写说明符"override"的方法没有重写任何基类方法。
在前面的继承当中我们提到:如果不想让某个类被继承,就用final关键词修饰。同样的,如果我们不想让派生类重写这个虚函数,那么也可以用 final 去修饰。

用final关键字修饰了之后,B类型中的析构函数就会发生这个报错。
2.8 重载、重写、隐藏的对比

3. 纯虚函数和抽象类
在虚函数的后面写上 = 0,则这个函数为纯虚函数,纯虚函数不需要定义实现 (实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

在这段代码当中基类Car里面的函数virtual void Drive() = 0;就是一个纯虚函数,因为Car这个类包含纯虚函数,所以Car这个类就是抽象类,并且可以看到它无法实例化出对象。但Banz这个类并不是抽象类,因为它的函数virtual void Drive()构成重写了。
4. 多态的原理
4.1 虚函数表指针和虚函数表
为了便于大家理解我们先来看这样一段代码:

对于这样一段代码,大家觉得最后的结果应该是什么?很多同学在这里会走入一个误区,因为int类型的数据是四个字节,char类型的数据是一个字节,目前一共是五个字节。再根据内存对齐规则最后的总内存大小应该是八个字节,所以就会认为输出结果是八个字节,但实际上:

最后的结果是12个字节。这是为什么呢?我们通过监视窗口来观察 b 这个对象中存储的内容:

大家会发现对象 b 当中,还有一个 void** 类型的指针 _vfptr。这个指针的名称就叫做:虚函数表指针。其中 v 代表 virtual ,f 代表function。
要了解虚函数表指针首先要知道虚函数表:虚函数表简称虚表,它是编译器为包含虚函数的类自动生成的一张全局只读数组,数组里存储的是类中所有虚函数的函数地址。简单说,它就是一个 "虚函数地址的清单",每个包含虚函数的类,或继承了虚函数的类,都有且仅有一份独立的虚函数表。
编译器会为每个包含虚函数的类的对象,自动添加一个隐藏的成员变量 ------ 虚函数表指针(vptr),这个指针的唯一作用就是指向所属类的虚函数表;对象的 vptr 在构造函数执行时被初始化,指向当前类的虚函数表;一个类的所有对象,共享同一份虚函数表,各自的 vptr 都指向这张表。
虚函数表有四个特性:
1. 唯一性:每个类(含派生类)的虚函数表是唯一的,不管创建多少对象,都共用这张表;
2. 继承性:派生类的虚函数表会先拷贝基类虚函数表的内容,若派生类重写了某虚函数,就替换表中对应位置的函数地址;
3. 内存占用:包含虚函数的对象,内存大小会增加一个指针的大小(32 位系统 4 字节,64 位系统 8 字节),就是 vptr 的占用;
4. 无虚函数则无表:如果一个类没有虚函数(也没继承虚函数),编译器不会为它生成虚函数表,对象也没有 vptr。
5. 独立性:派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
因此在上面的问题中,32位系统下虚函数指针的大小是4个字节,加上前面int和char类型的数据一共5个字节,相加就是9个字节,再根据内存对齐原则,变成12字节。
4.2 多态是如何实现的
我们来看这样一段代码:
cpp
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票全价" << endl;
}
private:
string _name;
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票打折" << endl;
}
private:
string _id;
};
class Soldier : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票优先" << endl;
}
private:
string _codename;
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Student st;
Soldier sd;
return 0;
}
在这段代码当中我们实例化出了两个对象,一个是st,一个是sd,然后通过监视窗口来观察其内部存储的内容:

从底层的角度 Func 函数中 ptr->BuyTicket (),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicket,ptr 指向 Student 对象调用 Student::BuyTicket 的呢?通过上图我们可以看到,满足多态条件后,底层不再是编译时 通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
我们可以看到,对于st和sd两个对象中存储的虚函数表指针指向的各自的虚函数表,存储的是对应的类域中的BuyTicket函数。
以st对象为例,在这里代码走的逻辑就是:
-
第一步(编译期):检查Student类是否有BuyTicket()的重写版本 → 有;
-
第二步(运行时):通过Student类的虚函数表查找BuyTicket() → 虚表中BuyTicket()的地址指向基类 Student
::BuyTicket(); -
最终结果:执行 Student
::BuyTicket()。
4.3 虚函数表的存储位置
既然知道了虚函数表的概念及用,那么它存储在内存空间中的哪个位置呢?我们用下面一段代码来进行验证:


虚函数表具体存储在哪个位置,这个问题严格说并没有标准答案C++标准并没有规定,我们在Visual Studio 2022下,可以看到虚表的位置是非常接近于常量区的,所以在vs2022中,虚表是存储在常量区当中的。
本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位指正或批评。