← 目录 / 第十二章 · 类与面向对象 / 12.9 虚函数与多态

12.9 虚函数与多态

同一行代码,根据对象的实际类型在运行时决定调用哪个函数——这就是多态。virtual 关键字是实现它的核心。

本页目录
12.9.1 上一节遗留的问题

上一节(12.8)提到:子类覆盖父类的方法是"静态绑定"——编译器在编译时就根据变量的声明类型决定调用哪个函数,而不是对象的实际类型。

这会导致一个让人困惑的现象:

C++ · 静态绑定的问题
1class Hero {
2public:
3 void Attack() { cout << "普通攻击" << endl; }
4};
5class Mage : public Hero {
6public:
7 void Attack() { cout << "释放火球术" << endl; }
8};
9
10int main() {
11 Mage m;
12 Hero* p = &m; // 父类指针指向子类对象(合法)
13 p->Attack(); // ⚠️ 输出:普通攻击 ← 调用了父类版本!
14 // p 的类型是 Hero*,编译器只看声明类型,不看实际对象
15}

明明 p 指向的是一个 Mage 对象,调用 Attack() 却还是执行了父类的版本——这显然不是我们想要的。我们希望:不管指针的类型是什么,都根据它实际指向的对象来调用函数。

这就是多态,而实现它只需要一个关键字:virtual

12.9.2 virtual 关键字

在父类的函数声明前加 virtual,这个函数就变成了虚函数。此后,通过父类指针或引用调用这个函数时,C++ 会在运行时查找对象的实际类型,调用对应的版本。

C++ · 加上 virtual,多态生效
1class Hero {
2public:
3 virtual void Attack() { cout << "普通攻击" << endl; }
4};
5class Mage : public Hero {
6public:
7 void Attack() { cout << "释放火球术" << endl; }
8};
9class Warrior : public Hero {
10public:
11 void Attack() { cout << "挥剑斩击" << endl; }
12};
13
14void DoAttack(Hero* hero) { // 接受父类指针
15 hero->Attack(); // 运行时决定调用哪个版本
16}
17
18int main() {
19 Mage m;
20 Warrior w;
21 DoAttack(&m); // 输出:释放火球术
22 DoAttack(&w); // 输出:挥剑斩击
23 // 同一行代码,根据实际对象类型执行不同行为——这就是多态
24}
静态绑定(无 virtual)
编译时决定,根据指针/引用的声明类型调用函数。
Hero* p 不管指向什么,都调用 Hero::Attack()
动态绑定(有 virtual)
运行时决定,根据指针实际指向的对象类型调用函数。
Hero* p 指向 Mage 就调用 Mage::Attack()
12.9.3 多态的工作原理:vtable

C++ 实现动态绑定靠的是虚函数表(vtable)。每个含有虚函数的类都有一张这样的表,记录着每个虚函数的实际地址。每个对象内部有一个隐藏的指针(vptr)指向自己类的 vtable。

调用虚函数时,程序先通过 vptr 找到 vtable,再从表里查到函数的真实地址来调用——这就是"运行时查表"。

Mage 对象 vptr ──────→ mana = 100 Warrior 对象 vptr ──────→ armor = 50 Mage::vtable Attack → Mage::Attack (其他虚函数…) Warrior::vtable Attack → Warrior::Attack (其他虚函数…) Mage::Attack() { 火球术 } Warrior::Attack() { 斩击 } 调用 hero->Attack():对象 → vptr → vtable → 查到实际函数地址 → 调用
每个含虚函数的对象内部都有一个隐藏的 vptr,指向自己类的虚函数表(vtable)。
vtable 里存的是每个虚函数的实际地址——Mage 的表里是火球术,Warrior 的表里是斩击。
调用时查表跳转,这就是"动态绑定"的实现原理。
💡
vtable 带来的开销:每个含虚函数的对象会多一个指针大小的空间(通常 8 字节),每次调用虚函数多一次查表操作。在绝大多数场景下这点开销可以忽略不计,但在极端性能敏感的代码里需要留意。
12.9.4 虚析构函数

如果你用父类指针管理子类对象,析构函数必须声明为 virtual,否则 delete 时只会调用父类的析构函数,子类的资源无法正确释放,导致内存泄漏。

C++ · 非虚析构的危险 vs 虚析构的正确做法
1class Hero {
2public:
3 // ❌ 非虚析构——用父类指针 delete 子类对象时,只调用 Hero::~Hero
4 ~Hero() { cout << "Hero 析构" << endl; }
5
6 // ✅ 正确做法:父类析构函数加 virtual
7 virtual ~Hero() { cout << "Hero 析构" << endl; }
8};
9
10class Mage : public Hero {
11public:
12 ~Mage() { cout << "Mage 析构" << endl; }
13};
14
15int main() {
16 Hero* p = new Mage();
17 delete p; // virtual 析构:先 Mage 析构,再 Hero 析构 ✅
18 // 非 virtual 析构:只调用 Hero 析构,Mage 的资源泄漏 ❌
19}
⚠️
记住这条规则:只要一个类会被继承,并且可能通过父类指针 delete 子类对象,就必须把父类的析构函数声明为 virtual。这是 C++ 多态使用中最常见的 bug 来源之一。
12.9.5 override 关键字

C++11 引入了 override 关键字,写在子类覆盖函数的末尾,告诉编译器"这个函数是故意覆盖父类的虚函数"。如果函数签名写错了(比如参数类型不对),编译器会立刻报错提醒你。

C++ · override 防止手误
1class Hero {
2public:
3 virtual void Attack(int damage) {}
4};
5
6class Mage : public Hero {
7public:
8 // 不写 override:参数写错了变成 float,变成新函数而非覆盖,没有警告
9 void Attack(float damage) {}
10
11 // 写 override:参数写错,编译器立刻报错 ✅
12 void Attack(int damage) override {}
13};
📖
本节小结
・在父类函数前加 virtual,调用时根据对象实际类型动态决定版本——这就是多态。
・多态要通过父类指针或引用来体现;直接用子类对象调用不涉及动态绑定。
・vtable 是动态绑定的底层实现:每个对象有 vptr,指向类的虚函数表,调用时查表跳转。
・父类析构函数必须是 virtual,否则通过父类指针 delete 子类对象会泄漏资源。
・子类覆盖虚函数时加上 override,让编译器帮你检查签名是否匹配。