多态是 C++ 面向对象编程(OOP)的三大核心特性之一(封装、继承、多态),它允许 “同一行为在不同对象上产生不同结果”,是实现代码灵活性与可扩展性的关键。例如 “买票” 行为,普通人全价、学生打折、军人优先 —— 这就是多态的直观体现。本文将从 “概念→实现条件→底层原理” 的路径,系统讲解 C++ 多态的核心机制,帮你彻底理解 “为什么基类指针能调用派生类函数”。
一、多态的概念:编译时与运行时的区别
多态本质是 “同一接口,多种实现”,但根据 “行为确定时机” 的不同,可分为两类:
| 多态类型 | 实现方式 | 确定时机 | 示例 |
|---|---|---|---|
| 编译时多态(静态多态) | 函数重载、函数模板 | 编译期 | add(1,2)(int 相加)与add(1.0,2.0)(double 相加) |
| 运行时多态(动态多态) | 虚函数 + 继承 | 运行期 | 基类指针指向不同对象,调用同一虚函数产生不同行为 |
本文重点讲解运行时多态—— 它是 C++ 多态的核心,也是面试高频考点。
二、多态的实现条件:三大核心要素
要实现运行时多态,必须满足三个严格条件,缺一不可:
- 继承关系:派生类必须继承自基类;
- 虚函数重写:派生类必须重写基类的虚函数(函数名、参数列表、返回值完全一致,协变除外);
- 基类指针 / 引用调用:必须通过基类的指针或引用调用虚函数。
2.1 关键概念 1:虚函数(Virtual Function)
虚函数是多态的 “开关”—— 在类成员函数前加virtual关键字,该函数即成为虚函数。注意:非成员函数(如全局函数)、静态成员函数(static)、构造函数不能被定义为虚函数;析构函数可以(且建议)定义为虚函数。
代码示例(虚函数定义):
class Person {
public:
// 虚函数:买票行为
virtual void BuyTicket() {
cout << "普通人买票:全价" << endl;
}
};
2.2 关键概念 2:虚函数重写(覆盖)
虚函数重写(也称 “覆盖”)是指:派生类中有一个与基类完全相同的虚函数(函数名、参数列表、返回值类型必须一致),派生类的虚函数会 “覆盖” 基类的虚函数。
重写的严格规则:
函数名、参数列表、返回值必须完全一致(协变除外,下文讲解);
基类函数必须加virtual,派生类函数可加可不加(但建议加,保证代码规范性);
访问限定符(public/protected)可不同(如基类public,派生类protected),不影响重写。
代码示例(虚函数重写):
class Person {
public:
virtual void BuyTicket() { // 基类虚函数
cout << "普通人买票:全价" << endl;
}
};
class Student : public Person { // 派生类继承基类
public:
virtual void BuyTicket() { // 重写基类虚函数(加virtual规范)
cout << "学生买票:半价" << endl;
}
};
class Soldier : public Person {
public:
void BuyTicket() { // 重写基类虚函数(不加virtual也可,但不推荐)
cout << "军人买票:优先" << endl;
}
};
2.3 关键概念 3:基类指针 / 引用调用
只有通过基类的指针或引用调用虚函数,才能触发多态 —— 若直接用派生类对象调用,无法体现多态特性(编译时已确定函数地址)。
代码示例(多态触发):
// 用基类指针调用虚函数
void Func(Person* ptr) {
ptr->BuyTicket(); // 行为由ptr指向的对象决定,而非指针类型
}
int main() {
Person p;
Student s;
Soldier sol;
Func(&p); // 指向基类对象,调用Person::BuyTicket → 输出“全价”
Func(&s); // 指向派生类对象,调用Student::BuyTicket → 输出“半价”
Func(&sol); // 指向派生类对象,调用Soldier::BuyTicket → 输出“优先”
return 0;
}
为什么必须用指针 / 引用?因为派生类对象赋值给基类对象时会发生 “切片”(仅复制基类部分),导致派生类独有的虚表信息丢失;而指针 / 引用不会复制对象,仅指向对象的内存地址,能完整访问对象的虚表(下文讲解虚表原理)。
三、虚函数重写的特殊情况
3.1 协变(Covariant):返回值可不同的特例
通常情况下,虚函数重写要求返回值类型完全一致,但协变允许返回值类型不同 —— 基类虚函数返回基类对象的指针 / 引用,派生类虚函数返回派生类对象的指针 / 引用。
代码示例(协变):
// 基类A和派生类B
class A {};
class B : public A {};
class Person {
public:
// 基类虚函数返回A*
virtual A* BuyTicket() {
cout << "普通人买票:全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
// 派生类虚函数返回B*(协变,合法重写)
virtual B* BuyTicket() {
cout << "学生买票:半价" << endl;
return nullptr;
}
};
注意:协变的实际应用场景极少,了解即可,无需深入。
3.2 析构函数的重写:避免内存泄漏
基类的析构函数若不加virtual,派生类的析构函数与基类析构函数构成 “隐藏”(而非重写),当用基类指针删除派生类对象时,仅会调用基类析构函数,导致派生类独有的资源未释放(内存泄漏)。
解决方案:将基类析构函数定义为虚函数,派生类析构函数会自动重写(编译器会将析构函数名统一处理为destructor())。
代码示例(析构函数重写):
class A {
public:
virtual ~A() { // 基类虚析构
cout << "~A()" << endl;
}
};
class B : public A {
public:
B() { _p = new int[10]; } // 派生类申请资源
~B() { // 自动重写基类虚析构
delete[] _p;
cout << "~B()" << endl;
}
private:
int* _p;
};
int main() {
A* p1 = new A;
A* p2 = new B;
delete p1; // 调用~A() → 正确
delete p2; // 先调用~B(),再调用~A() → 资源全部释放,无泄漏
return 0;
}
面试高频考点:为什么基类析构函数建议定义为虚函数?答:为了保证用基类指针删除派生类对象时,能完整调用派生类析构函数,避免内存泄漏。
3.3 override 与 final:C++11 的重写检查
虚函数重写要求严格,若因疏忽写错函数名(如BuyTickit)或参数列表,编译器不会报错,但运行时无法触发多态。C++11 提供两个关键字解决此问题:
| 关键字 | 作用 | 示例 |
|---|---|---|
override | 修饰派生类虚函数,强制检查是否重写基类虚函数(未重写则编译报错) | virtual void BuyTicket() override; |
final | 修饰基类虚函数,禁止派生类重写该函数;也可修饰类,禁止类被继承 | virtual void BuyTicket() final; |
代码示例(override 检查):
class Car {
public:
virtual void Drive() {} // 基类虚函数
};
class Benz : public Car {
public:
// 错误:函数名写错(Dirve≠Drive),override检查到未重写,编译报错
virtual void Dirve() override {
cout << "Benz:舒适" << endl;
}
};
代码示例(final 禁止重写):
class Car {
public:
virtual void Drive() final {} // 禁止派生类重写
};
class Benz : public Car {
public:
// 错误:Drive被final修饰,无法重写,编译报错
virtual void Drive() {
cout << "Benz:舒适" << endl;
}
};
四、纯虚函数与抽象类
在虚函数后加=0,该函数即为纯虚函数;包含纯虚函数的类称为抽象类(也叫接口类)。
4.1 抽象类的特性:
- 抽象类不能实例化对象(编译报错);
- 派生类必须重写抽象类的所有纯虚函数,否则派生类也是抽象类(无法实例化);
- 抽象类的指针 / 引用可以指向派生类对象(用于多态)。
代码示例(纯虚函数与抽象类):
// 抽象类:包含纯虚函数Drive()
class Car {
public:
virtual void Drive() = 0; // 纯虚函数
};
// 派生类Benz:重写纯虚函数,可实例化
class Benz : public Car {
public:
virtual void Drive() {
cout << "Benz:舒适" << endl;
}
};
// 派生类BMW:重写纯虚函数,可实例化
class BMW : public Car {
public:
virtual void Drive() {
cout << "BMW:操控" << endl;
}
};
int main() {
// Car c; // 错误:抽象类不能实例化对象
Car* pBenz = new Benz; // 抽象类指针指向派生类对象
Car* pBMW = new BMW;
pBenz->Drive(); // 调用Benz::Drive → 输出“舒适”
pBMW->Drive(); // 调用BMW::Drive → 输出“操控”
delete pBenz;
delete pBMW;
return 0;
}
4.2 抽象类的用途:
强制派生类实现接口:纯虚函数相当于 “接口规范”,派生类必须按规范实现功能(如Car抽象类强制所有车型实现Drive方法);
实现多态的基础:抽象类的指针 / 引用是多态调用的 “统一入口”,便于代码扩展(新增车型时,无需修改原有调用逻辑)。
五、多态的底层原理:虚表与虚表指针
多态的本质是通过 “虚表(Virtual Table) ” 和 “虚表指针(Virtual Table Pointer, vfptr) ” 实现的 —— 这是理解多态的关键,也是面试必问考点。
5.1 虚表指针(vfptr):对象的 “多态开关”
当类中包含虚函数时,编译器会在该类的每个对象中自动添加一个隐藏的成员变量 —— 虚表指针(vfptr),它指向一张 “虚函数表”(简称虚表)。
验证虚表指针的存在:
class Base {
public:
virtual void Func1() {} // 虚函数
protected:
int _b = 1; // 成员变量
char _ch = 'x';
};
int main() {
Base b;
cout << sizeof(b) << endl; // 32位系统输出12,64位输出16
return 0;
}
结果分析:
32 位系统中,int(4 字节)+ char(1 字节,内存对齐后 4 字节)= 8 字节,但实际sizeof(b)=12—— 多出来的 4 字节就是虚表指针(vfptr);
64 位系统中,虚表指针占 8 字节,内存对齐后总大小为 16 字节。
5.2 虚表(vtable):存储虚函数地址的数组
虚表是编译器为每个包含虚函数的类生成的一张 “函数地址表”,本质是一个存储虚函数指针的数组(数组末尾通常有一个nullptr标记,不同编译器可能不同)。
虚表的生成规则:
- 基类的虚表:存储基类所有虚函数的地址;
- 派生类的虚表:
- 先复制基类虚表的所有内容;
- 若派生类重写了基类的某个虚函数,用派生类虚函数的地址 “覆盖” 虚表中对应的基类虚函数地址;
- 若派生类有自己的新虚函数,将其地址添加到虚表末尾。
虚表的位置:
虚表是 “类级别的资源”,所有同类型对象共用一张虚表,存储在代码段(常量区)(而非堆或栈)—— 可通过内存地址对比验证(虚表地址与字符串常量地址接近)。
5.3 多态的实现流程:动态绑定
当通过基类指针 / 引用调用虚函数时,编译器会触发 “动态绑定”(运行时确定函数地址),具体流程如下:
- 从基类指针 / 引用指向的对象中,获取虚表指针(
vfptr); - 通过虚表指针找到对应的虚表;
- 在虚表中找到要调用的虚函数的地址;
- 调用该地址对应的虚函数。
示例图解(以Person、Student类为例):
Person对象的vfptr指向Person的虚表,虚表中存储Person::BuyTicket的地址;
Student对象的vfptr指向Student的虚表,虚表中Person::BuyTicket的地址已被Student::BuyTicket覆盖;
当Func(&s)调用时,通过Student对象的vfptr找到Student的虚表,调用Student::BuyTicket。
5.4 动态绑定 vs 静态绑定
静态绑定:函数地址在编译时确定,适用于非虚函数调用(如普通函数、静态函数);
动态绑定:函数地址在运行时确定,适用于虚函数调用(通过基类指针 / 引用)。
汇编代码对比:
// 动态绑定(虚函数调用)
ptr->BuyTicket();
// 汇编关键指令:从对象中取vfptr → 从虚表中取函数地址 → 调用
00 EF2001 mov eax,dword ptr [ptr] // eax = 对象地址(含vfptr)
00 EF2004 mov edx,dword ptr [eax] // edx = vfptr(虚表地址)
00 EF200B mov eax,dword ptr [edx] // eax = 虚表中第一个虚函数地址
00 EF200D call eax // 调用虚函数
// 静态绑定(非虚函数调用)
ptr->BuyTicket();
// 汇编关键指令:直接调用固定地址的函数
00 EA2C94 call Person::BuyTicket (0EA153Ch)
六、易混淆概念对比:重载、重写、隐藏
C++ 中函数的 “同名现象” 有三种:重载、重写、隐藏,极易混淆,需明确区分:
| 特性 | 重载(Overload) | 重写(Override) | 隐藏(Hide) |
|---|---|---|---|
| 作用域 | 同一类(或同一作用域) | 基类与派生类(不同作用域) | 基类与派生类(不同作用域) |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同(类型 / 个数 / 顺序) | 必须相同 | 可相同可不同 |
| 返回值 | 可相同可不同 | 必须相同(协变除外) | 可相同可不同 |
| 虚函数要求 | 无 | 基类必须是虚函数,派生类可加 | 无 |
| 调用方式 | 编译时确定(静态绑定) | 运行时确定(动态绑定,需指针 / 引用) | 编译时确定(静态绑定) |
示例对比:
class A {
public:
// 重载:同一作用域,函数名相同,参数不同
void func(int x) {}
void func(double x) {}
// 虚函数:用于重写
virtual void show() {}
};
class B : public A {
public:
// 重写:基类虚函数,函数名、参数、返回值相同
virtual void show() {}
// 隐藏:函数名相同,不构成重写(基类func非虚函数)
void func(int x) {}
};
七、总结
多态是 C++ 面向对象编程的灵魂,其核心要点可总结为:
- 实现条件:继承 + 虚函数重写 + 基类指针 / 引用调用;
- 关键概念:虚函数(
virtual)、纯虚函数(=0)、抽象类(含纯虚函数)、虚表(存储虚函数地址)、虚表指针(对象指向虚表的指针); - 底层原理:通过虚表指针找到虚表,运行时动态绑定虚函数地址,实现 “同一接口,多种行为”;
- 实战建议:基类析构函数务必定义为虚函数,用
override检查重写,优先用抽象类定义接口规范。
3842

被折叠的 条评论
为什么被折叠?



