虚函数(virtual)="这个函数,请在运行时按对象的真实身份来决定调谁的版本"。它要生效需要三个条件同时满足:① 通过指针或引用调用;② 函数是虚函数;③ 子类的签名和父类完全一致。机制上,编译器给每个对象藏一个指针(指向"函数地址表"),调用时"顺着指针去表里查"——这就是动态绑定。必须背下来的铁律:只要类会被"父类指针 + delete"管理,它的析构函数就必须是 virtual,否则子类的析构函数根本不会跑。
11.4 用 Demo 展示了"加一个 virtual,行为就变了",本节回答"为什么"和"要注意什么"。它是 11.1.5"隐藏"的镜像:没有 virtual → 编译期按类型找名字(隐藏);有 virtual → 运行期按对象身份找函数(重写)。另外,本节的虚析构函数会兑现 10.3 埋下的伏笔(那时说"析构函数在继承里有个新坑")。
一、三个必要条件(缺一不可)
动态绑定要生效,必须同时满足:
| # | 条件 | 说明 |
|---|---|---|
| ① | 通过"指针"或"引用"调用 | Base *p = &d; p->f(); 或 Base &r = d; r.f();。如果用"对象本身"调用(d.f()),编译器根本不需要动态——它明明白白知道是 Derived |
| ② | 调用的函数是虚函数 | 父类那个函数前面有 virtual |
| ③ | 签名完全一致(函数名、参数类型、const 修饰) |
差一个字(比如 const)就变成了隐藏(11.1.5),而不是重写 |
第③条最容易出错,看这个经典 bug:
class Base {
public:
virtual void f() { cout << "Base" << endl; }
};
class Derived : public Base {
public:
void f() const { cout << "Derived" << endl; } // ★ 多了 const!
};
Base *p = new Derived;
p->f(); // 输出 "Base"!—— 因为签名不同(一个 const 一个不 const),
// Derived::f 不是"重写",而是"隐藏"
明明是想要多态,结果却调到了父类版本——因为"签名差一个 const",两条路就岔开了。这类 bug 编译器不报错,只在你运行时发现"怎么调用的是父类的"——极难查。
解药(现代写法):override
class Derived : public Base {
public:
void f() const override { ... } // ★ 加 override
};
override 是个"保险栓":它告诉编译器"我本意是重写父类的虚函数"。如果签名不一致(比如少写了 const),编译器会直接报错——把"运行时才发现"变成"编译时就发现"。
强烈建议:凡是重写虚函数,都写上
override(老编译器不认,那就不写;但能写就写)。
二、机制:对象里藏了一个指针(知道原理就够了)
编译器在背后做了两件事:
- 给每个"有虚函数的类"造一张表(一般叫
vtable,虚函数表),表里按固定顺序存着各个虚函数的地址。 - 给每个"这种类的对象"加一个隐藏的指针成员(一般叫
vptr),指向上面那张表。这个指针构造函数里自动设定——构造哪一层,就指向那一层的表。
调用 p->f() 时:
① 取 p 指向的对象里的隐藏指针(vptr)
② 顺着它找到"这个对象真正属于哪个类"的那张表(vtable)
③ 在表里查到 f 的地址
④ 跳过去执行
一句话:"看对象的表,不看指针的类型"——所以是"运行时才定"。
由此推出三个实用结论:
| 结论 | 说明 |
|---|---|
| 每个对象多一个隐藏指针 | sizeof 会变大(别用 sizeof 猜虚函数类的字节数,让它自己算) |
| 有虚函数的类,构造/析构期间"表"在切换 | 所以构造/析构函数里调用虚函数,拿到的永远是"当前这一层"的版本,不是最子类的(11.1.3 提过) |
| 一点点性能开销 | 一次间接跳转。绝大多数场合可以忽略 |
三、铁律:基类的析构函数必须是虚的
这是全章最实用、最救命的一条规则。
问题现场:
class Base {
public:
~Base() { cout << "~Base" << endl; } // ★ 不是虚的
};
class Derived : public Base {
int *data;
public:
Derived() { data = new int[100]; } // 子类申请了资源
~Derived() { delete[] data; cout << "~Derived" << endl; }
};
int main() {
Base *p = new Derived; // 父类指针,管子类对象
delete p; // ★ 只输出 ~Base!~Derived 根本没跑
return 0;
}
发生了什么:delete p 时,p 的类型是 Base*、而 ~Base 不是虚的,于是编译器直接调 Base::~Base——子类的析构函数从来不执行,data 那块内存永久泄漏。
改一个字就好了:
class Base {
public:
virtual ~Base() { cout << "~Base" << endl; } // ← virtual
};
现在 delete p 会依次输出 ~Derived、~Base(子类先、父类后,11.1.3 的顺序)。
规则一句话:
只要"可能被父类指针
delete",父类的析构函数就必须是virtual。 判据:这个类有没有虚函数?有虚函数 → 它就会被"当父类用" → 析构函数也要虚。
为什么"有虚函数就要虚析构":因为有虚函数,就说明你打算用"父类指针/引用"来管理子类对象(那是虚函数唯一的使用场景)——那么 delete 也一定会通过父类指针发生。两件事总是同时出现。
四、动手 Demo:虚函数 + 虚析构
#include <iostream>
using namespace std;
class Base {
public:
virtual void speak() { cout << "Base::speak" << endl; }
virtual ~Base() { cout << "~Base" << endl; } // ★ 虚析构
};
class Derived : public Base {
public:
void speak() { cout << "Derived::speak" << endl; } // 自动是虚的(可省 virtual)
~Derived() { cout << "~Derived" << endl; }
};
int main() {
Derived d;
Base *p = &d;
p->speak(); // ★ 多态生效
cout << "── delete 一个堆上的子类对象 ──" << endl;
Base *q = new Derived;
delete q; // 因为 ~Base 是虚的,~Derived 也会跑
cout << "── main 结束,局部对象 d 开始析构 ──" << endl;
return 0;
}
看到什么算成功:
Derived::speak
── delete 一个堆上的子类对象 ──
~Derived
~Base
── main 结束,局部对象 d 开始析构 ──
~Derived
~Base
读结果:
| 现象 | 说明 |
|---|---|
p->speak() 输出 Derived::speak |
① 指针调用 + ② 虚函数 + ③ 签名一致 → 动态绑定生效 |
delete q 时先 ~Derived 再 ~Base |
虚析构让子类析构也执行了——资源被正确释放 |
30 秒实验(这个实验的本质要记住):
1. 把 virtual ~Base() 里的 virtual 删掉,重新编译运行。
2. 观察 delete q; 那一段的输出:只剩 ~Base,~Derived 不见了。
3. 这就是内存泄漏——真实项目里,Derived 构造里 new 的东西永远回收不了。
4. 加回 virtual,就好。
5. 然后问自己一句话:"如果 Base 里任何虚函数都没有(也没有 virtual),我还要虚析构吗?"——不需要(因为没人会拿 Base* 去 delete 一个 Derived;那本身就是设计禁止的)。这条推理就是"有虚函数 → 要有虚析构"的由来。
五、常见误区
- "子类的
virtual必须也写上"——不用(虚性会遗传)。但写完更清楚,而且现代做法是写override——它连"签名写错了"都能替你抓出来。 - "对象本身调用也能多态"——不需要,也不会。
d.f()时编译器明知道是Derived,直接调就行。"动态绑定"存在的理由,恰恰是"编译器不知道p到底指谁"。 - "签名差一个
const也算重写"——不算。差一个字就是"隐藏"(11.1.5),运行时你会发现"怎么调的是父类的",而且编译器不报错。这就是要写override的原因。 - "析构函数可以不虚,我不
delete就没事"——你不能保证。类会被别人用,用的人可能把对象放进容器、交给智能指针、传进别的函数(那些地方接受的往往是父类指针/引用)。只要类有虚函数,就把它当"要被继承的基类"对待,析构就写virtual。 - "虚函数越多越好"——不是。虚函数意味着"设计上的开放点",每次调用都多一层间接;而且一旦虚了,就再也不能"变回不虚"。只在真正需要"变化点"的地方放虚函数(形状的
area、设备的send……)。其余的写成普通函数。 - "构造函数可以是虚函数"——不行。因为虚函数机制依赖"对象里那个隐藏指针",而在构造函数执行前,那个指针还没设好。构造函数不是虚的,也不能虚。
自测三问(点开对答案)
1. 动态绑定生效的三个必要条件是什么?——**① 通过指针或引用调用**;**② 函数是虚函数**;**③ 子类签名与父类完全一致**(函数名、参数类型、`const` 都要一样)。**差一条就退化成"隐藏"或"静态绑定"。** 2. 为什么有虚函数的类,析构函数也要写成 `virtual`?——因为**有虚函数就意味着这个类会被"父类指针/引用"管理**,那么 `delete` 也很可能通过父类指针发生。**`~Base` 不虚时,`delete p;` 只调 `~Base`,子类的析构函数不执行 → 子类申请的资源永久泄漏。** 3. `override` 有什么好处?——它是"**我本意是重写父类虚函数**"的声明:**如果签名不一致(忘了 `const`、参数写错),编译器会直接报错**,把"运行期才发现的诡异 bug"提前到**编译期**。- ☐ 11.4.2 利用虚函数实现动态绑定(下一节)
相关笔记
- 多态 —— 上一节:一个词带来的差别
- 利用虚函数实现动态绑定(含实例06) —— 下一节:形状数组的实战
- 子类隐藏父类的成员函数(含实例03) —— 没有 virtual 时会发生什么
- 析构函数 —— 析构顺序与"三法则"
- 构造函数的调用顺序(含实例01) —— 为什么构造里调虚函数没用
- 🔷 C++基础 · 索引 —— 全课路线图