多态是 C++ 面向对象三大特性(封装、继承、多态)的灵魂,也是 C++ 面向对象编程的核心能力。如果说封装是把类的属性和行为打包、继承是实现类层次的代码复用,那么多态就是让继承体系中的类拥有 "多种形态",实现接口统一,行为差异化,也是 C++ 实现面向接口编程、代码高扩展性的基础。
本文将从零开始,围绕多态的核心概念、实现条件、语法细节,结合实战代码示例,帮你学习 C++ 多态。
一、多态的核心概念:什么是多态?
通俗来说,多态就是 "同一种行为,不同的对象执行,会产生不同的结果"。
举个最经典的现实例子:同样是 "买票" 这个行为,普通人买票是全价,学生买票是半价优惠,军人买票是优先购票;同样是 "动物叫" 这个行为,猫对象执行是 "喵",狗对象执行是 "汪汪"。同一种行为,不同对象执行,产生了完全不同的结果,这就是多态。
在 C++ 中,多态分为两大类,二者的核心区别在于 "确定函数调用的时机不同":
| 多态类型 | 别名 | 核心实现 | 函数调用确定时机 |
|---|---|---|---|
| 编译时多态 | 静态多态 | 函数重载、函数模板 | 编译阶段就确定了要调用的函数 |
| 运行时多态 | 动态多态 | 继承 + 虚函数重写 + 基类指针 / 引用调用 | 程序运行时,根据指向的对象确定要调用的函数 |
二、多态的实现:两个必要条件,缺一不可
运行时多态的实现,必须同时满足以下两个核心条件,少一个都无法形成多态:
1.继承体系中,派生类必须完成对基类虚函数的重写(也叫覆盖)**; 2.必须通过基类的指针或者引用,去调用这个虚函数。
下面我们逐一拆解这两个条件的核心细节。
2.1 基础概念:虚函数
什么是虚函数?在类的非静态成员函数前面,加上virtual关键字修饰,这个函数就叫做虚函数。
注意两个核心限制:
只有类的非静态成员函数可以被定义为虚函数,全局函数、静态成员函数都不能加virtual;
构造函数不能是虚函数,析构函数可以(而且很多场景下必须是)虚函数。
cpp
class Person {
public:
// 虚函数:加virtual修饰的成员函数
virtual void BuyTicket() {
cout << "普通人买票:全价" << endl;
}
};
2.2 核心规则:虚函数的重写(覆盖)
虚函数的重写(也叫覆盖),是多态的核心前提。派生类中有一个和基类完全相同的虚函数,就称派生类的虚函数重写了基类的虚函数。
这里的 "完全相同",有严格的三要素要求:
函数名完全相同 参数列表(个数、类型、顺序)完全相同 返回值类型完全相同(唯一例外:协变,后面会讲)
同时,两个函数必须都是虚函数:基类函数必须加virtual,派生类函数可以不加virtual(因为继承了基类的虚函数属性,依然保持虚函数特性),但这种写法不规范,强烈建议派生类重写的虚函数也加上virtual,提升代码可读性。
2.3 完整的多态代码示例
我们用经典的买票场景,实现一个完整的多态示例:
cpp
#include <iostream>
using namespace std;
// 基类:人
class Person {
public:
// 虚函数:买票行为
virtual void BuyTicket() {
cout << "普通人买票:全价" << endl;
}
};
// 派生类:学生,公有继承Person
class Student : public Person {
public:
// 重写基类的虚函数
virtual void BuyTicket() override { // C++11 override关键字,辅助检查重写
cout << "学生买票:半价优惠" << endl;
}
};
// 派生类:军人,公有继承Person
class Soldier : public Person {
public:
// 重写基类的虚函数
virtual void BuyTicket() override {
cout << "军人买票:优先购票" << endl;
}
};
// 多态调用:基类引用作为参数
void Buy(Person& people) {
// 核心:基类引用调用虚函数,触发多态
// 运行时根据传入的对象类型,决定调用哪个类的BuyTicket
people.BuyTicket();
}
int main() {
Person p;
Student s;
Soldier so;
// 同一句代码,传入不同的对象,执行不同的行为,这就是多态
Buy(p); // 传入普通人对象,调用Person::BuyTicket
Buy(s); // 传入学生对象,调用Student::BuyTicket
Buy(so); // 传入军人对象,调用Soldier::BuyTicket
return 0;
}
程序运行输出:
cpp
普通人买票:全价
学生买票:半价优惠
军人买票:优先购票
可以看到,Buy函数中只有一句people.BuyTicket(),但传入不同的对象,就执行了不同的函数逻辑,完美实现了 "同一种行为,不同结果" 的多态特性。
2.4 多态调用的核心特点
多态的调用,不看指针 / 引用本身的类型,只看指针 / 引用指向的对象的类型:
如果指向基类对象,就调用基类的虚函数;
如果指向派生类对象,就调用派生类重写的虚函数。
这和非多态的普通函数调用完全相反:普通函数调用,编译时就根据指针 / 引用的类型确定了调用的函数,和指向的对象无关。
三、虚函数重写的特殊场景与语法细节
3.1 协变(了解即可)
协变是虚函数重写三要素的唯一例外:派生类重写基类虚函数时,返回值类型可以不同,但必须是父子类关系的指针或引用。
具体来说:基类虚函数返回基类对象的指针 / 引用,派生类虚函数返回派生类对象的指针 / 引用,这种情况依然构成重写,称为协变。
cpp
class A {};
class B : public A {};
class Person {
public:
// 基类虚函数返回A*
virtual A* BuyTicket() {
cout << "普通人买票:全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
// 派生类虚函数返回B*,B是A的派生类,构成协变,依然是重写
virtual B* BuyTicket() override {
cout << "学生买票:半价" << endl;
return nullptr;
}
};
协变在实际开发中使用极少,我们只需要了解这个语法规则即可,面试中偶尔会作为选择题考点出现。
3.2 析构函数的重写
这是开发中很重要的重写场景,基类的析构函数建议定义为虚函数。
为什么基类析构函数要加 virtual?
我们先看一个反面示例
cpp
class A {
public:
// 基类析构函数不加virtual
~A() {
cout << "~A() 基类析构" << endl;
}
};
class B : public A {
public:
B() {
// 派生类构造时申请内存
_p = new int[10];
}
~B() {
cout << "~B() 派生类析构,释放资源" << endl;
delete[] _p; // 派生类析构时释放资源
}
private:
int* _p;
};
int main() {
A* p1 = new A;
A* p2 = new B; // 基类指针指向派生类对象
delete p1;
delete p2; // 这里会发生内存泄漏!
return 0;
}
上面的代码运行后,delete p2时,只会调用A的析构函数,不会调用B的析构函数,导致B中申请的内存无法释放,造成内存泄漏。
根本原因:基类析构函数不加 virtual,delete p2时不会触发多态,只会根据指针类型A*,调用A的析构函数。
解决方案:基类析构函数加 virtual
我们给基类析构函数加上virtual,问题就解决了:
cpp
class A {
public:
// 基类析构函数定义为虚函数
virtual ~A() {
cout << "~A() 基类析构" << endl;
}
};
class B : public A {
public:
B() { _p = new int[10]; }
// 派生类析构函数,无论是否加virtual,都和基类构成重写
~B() {
cout << "~B() 派生类析构,释放资源" << endl;
delete[] _p;
}
private:
int* _p;
};
int main() {
A* p2 = new B;
delete p2; // 触发多态,先调用B的析构,再调用A的析构,资源完全释放
return 0;
}
为什么析构函数名不同,还能构成重写?
编译器会对所有类的析构函数名做特殊处理,编译后统一处理成destructor(),所以基类和派生类的析构函数名看似不同,实际编译后是相同的,只要基类析构函数加了virtual,就会构成重写。
开发规范:只要一个类会被作为基类继承,它的析构函数就必须定义为虚函数。
3.3 C++11 辅助关键字:override 和 final
C++11 新增了两个关键字,专门用于辅助虚函数重写,解决了传统写法中 "重写失败编译不报错" 的痛点。
- override:检查是否完成重写
override关键字加在派生类虚函数的末尾,强制编译器检查该函数是否重写了基类的虚函数。如果没有重写(比如函数名、参数写错了),编译器会直接报错,提前暴露问题,避免运行时才发现 bug。
cpp
class Car {
public:
virtual void Drive() {}
};
class Benz : public Car {
public:
// 正确重写,编译通过
virtual void Drive() override { cout << "奔驰:舒适驾驶" << endl; }
// 错误示例:函数名写错,加了override会直接编译报错
// virtual void Dirve() override { cout << "错误写法" << endl; }
};
2. final:禁止虚函数被重写 / 禁止类被继承
final关键字有两个核心用法:
加在基类虚函数的末尾,禁止该虚函数被派生类重写,派生类如果尝试重写,编译会直接报错;
加在类名的后面,禁止该类被继承,任何类尝试继承该类,编译会直接报错。
cpp
// 用法1:禁止虚函数被重写
class Car {
public:
// Drive函数被final修饰,派生类无法重写
virtual void Drive() final {}
};
class Benz : public Car {
public:
// 编译报错:final修饰的函数无法被重写
// virtual void Drive() override {}
};
// 用法2:禁止类被继承
class Base final {};
// 编译报错:final修饰的类无法被继承
// class Derive : public Base {};
四、重载、重写、隐藏的区别
这三个概念很多初学者很容易混淆,我们用一张表格和通俗的解释彻底讲清楚三者的区别:
| 概念 | 作用域 | 核心要求 | 关键字要求 |
|---|---|---|---|
| 函数重载 | 同一个作用域内 | 函数名相同,参数列表(个数/类型/顺序)不同,与返回值无关 | 无要求 |
| 重写(覆盖) | 继承体系的基类和派生类,两个不同作用域 | 1. 函数名、参数列表、返回值完全相同(协变例外) 2. 两个函数必须都是虚函数 | 基类必须加 virtual |
| 隐藏(重定义) | 继承体系的基类和派生类,两个不同作用域 | 1. 函数名相同,不构成重写的,都构成隐藏 2. 派生类和基类的同名成员变量,也构成隐藏 | 无要求 |
| 通俗记忆: | |||
| 重载:同一个类里,同名函数,参数不同,就是重载; | |||
| 重写:父子类里,虚函数,三要素完全相同,就是重写,是多态的前提; | |||
| 隐藏:父子类里,同名函数 / 变量,不构成重写,就一定是隐藏,派生类会屏蔽基类的同名成员。 |
五、纯虚函数和抽象类
5.1 什么是纯虚函数?
在虚函数的声明后面加上=0,这个函数就叫做纯虚函数。纯虚函数一般只需要声明,不需要写函数实现(语法上可以写,但没有实际意义)
cpp
class Car {
public:
// 纯虚函数:声明后加=0
virtual void Drive() = 0;
};
5.2 什么是抽象类?
包含纯虚函数的类,叫做抽象类(也叫接口类) 。抽象类有一个核心特性:不能实例化出对象 。
如果派生类继承了抽象类,但没有重写所有的纯虚函数,那么这个派生类也还是抽象类,依然不能实例化对象。只有派生类重写了所有的纯虚函数,才能实例化对象。
cpp
class Car {
public:
// 纯虚函数,Car成为抽象类
virtual void Drive() = 0;
};
class Benz : public Car {
public:
// 重写纯虚函数
virtual void Drive() override {
cout << "奔驰:舒适驾驶" << endl;
}
};
class BMW : public Car {
public:
// 重写纯虚函数
virtual void Drive() override {
cout << "宝马:操控驾驶" << endl;
}
};
int main() {
// Car car; // 编译报错:抽象类无法实例化对象
Car* pBenz = new Benz; // 可以用抽象类的指针/引用指向派生类对象
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
5.3 抽象类的核心作用
抽象类的核心价值,就是强制派生类必须重写接口,实现了接口定义和实现的分离,也就是面向接口编程 。
抽象类只定义 "类应该有什么行为",不关心行为的具体实现,具体实现交给派生类去完成。比如上面的Car抽象类,只定义了 "汽车必须有驾驶这个行为",但具体是舒适驾驶还是操控驾驶,交给奔驰、宝马这些派生类自己实现。这是 C++ 实现多态、高扩展性代码的核心设计思想。
六、多态的底层原理:虚函数表与动态绑定
很多初学者会好奇:多态到底是怎么实现的?为什么同一句代码,运行时能根据对象类型调用不同的函数?这一切的底层,都离不开虚函数表指针(vfptr)和虚函数表(虚表)。
6.1 虚函数表指针(vfptr)
我们先看一个选择题:
cpp
class Base {
public:
virtual void Func1() {}
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
cout << sizeof(Base) << endl; // 32位程序下,输出结果是多少?
return 0;
}
很多初学者会算:int 占 4 字节,char 占 1 字节,内存对齐后总共 8 字节。但实际 32 位程序下,运行结果是12 字节。
多出来的 4 字节,就是虚函数表指针__vfptr。只要一个类包含虚函数,它的对象中就会自动包含一个虚函数表指针,放在对象内存布局的最开头(不同平台可能有差异,VS 系列编译器放在开头),这个指针指向一张虚函数表。
6.2 虚函数表(虚表)
虚函数表,本质是一个存储虚函数指针的数组 ,一个类的所有虚函数的地址,都会被放到这个类对应的虚表中。
核心规则:
同类型的对象,共享同一张虚表 :同一个类的所有对象,虚函数表指针都指向同一张虚表;
基类和派生类有各自独立的虚表 :即使是继承关系,基类和派生类也有自己的虚表,不会共用;
重写会覆盖虚表中的函数地址 :如果派生类重写了基类的虚函数,派生类虚表中对应的函数地址,会被替换成派生类重写的函数地址;没有重写的虚函数,直接继承基类的地址;
虚表的末尾,一般会有一个nullptr作为结束标记(不同编译器实现略有差异)。
我们用一个示例看基类和派生类的虚表区别:
cpp
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
protected:
int a = 1;
};
class Derive : public Base {
public:
// 重写基类的func1
virtual void func1() override { cout << "Derive::func1" << endl; }
// 派生类自己的虚函数func3
virtual void func3() { cout << "Derive::func3" << endl; }
protected:
int b = 2;
};
| 基类 Base 的虚表 | 派生类 Derive 的虚表 |
|---|---|
| &Base::func1 | &Derive::func1(重写后,覆盖了基类的地址) |
| &Base::func2 | &Base::func2(未重写,继承基类地址) |
| 结束标记 nullptr | &Derive::func3(派生类自己的虚函数) |
| 结束标记 nullptr |
6.3 多态的本质:动态绑定与静态绑定
理解了虚表,我们就能彻底搞懂多态的实现原理,核心就是动态绑定和静态绑定。
- 静态绑定(编译时绑定)
不满足多态条件的函数调用,都是静态绑定。编译器在编译阶段,就直接确定了要调用的函数地址 ,编译后就固定死了,运行时不会改变。
比如普通函数调用、对象调用虚函数、非虚函数调用,都是静态绑定。 - 动态绑定(运行时绑定)
满足多态条件的函数调用(基类指针 / 引用调用虚函数),就是动态绑定。编译器在编译阶段,无法确定要调用哪个函数,只有程序运行时,通过指针 / 引用指向的对象的虚表,找到对应的虚函数地址,才完成函数调用 。
我们看底层汇编代码,就能清晰看到区别:
cpp
// 多态调用:动态绑定
void Buy(Person* ptr) {
ptr->BuyTicket();
// 底层汇编逻辑:
// 1. 从ptr指向的对象中,取出虚表指针vfptr
// 2. 从虚表中取出对应虚函数的地址
// 3. 调用这个虚函数
}
这就是多态的核心原理:运行时,根据指向的对象,找到对应的虚表,再从虚表中找到要调用的虚函数,实现了不同对象执行不同行为。
6.4 常见问题答疑
虚函数存在哪里?
虚函数和普通函数一样,编译后是一段二进制指令,存储在代码段(常量区) ,只是它的地址被存到了虚表中。
虚函数表存在哪里?
C++ 标准没有明确规定,但主流编译器(VS、GCC)都把虚表存储在代码段(常量区) ,属于只读数据,程序运行期间不会修改。
inline 函数可以是虚函数吗?
语法上可以给 inline 函数加 virtual,但 inline 是编译时展开,虚函数是运行时动态绑定,二者是矛盾的。编译器会忽略 inline 属性,因为多态调用无法在编译时确定函数,无法展开。
静态成员函数可以是虚函数吗?
不可以。静态成员函数属于整个类,没有 this 指针,无法访问对象的虚表指针,无法实现动态绑定。