析构函数是"对象临终时自动执行的那个函数"——构造函数管"进门手续",它管"出门善后"。长相是 ~类名()(类名前一个波浪号),没有参数、没有返回类型、不能重载(一个类只能有一个)。它的主要任务是把对象借用的资源还回去(释放内存、关文件、断开连接)。析构顺序和构造顺序正好相反:后造出来的先销毁。
这是"对象一生"的另一个端点:10.2 讲出生,本节讲收场。它和 10.2.2 的复制构造函数是一对搭档——有指针成员的类,必须同时管好"出生时怎么复制"和"临终时怎么释放"(本节第五节的"三法则"就是把这个道理说成规矩)。另外,8.5 讲的栈上的局部变量"离开作用域就消失",本节会告诉你"消失"的那一瞬间到底发生了什么。
一、长相与三条硬规矩
class Holder {
public:
~Holder() { // ← 析构函数
// 善后
}
};
| 规矩 | 说明 |
|---|---|
名字=~类名 |
波浪号 ~(键盘左上角,Shift + 那个键) |
| 没有参数 | 所以也不能重载——一山不容二虎,析构函数只有一个 |
| 没有返回类型 | 和构造函数一样,连 void 都不写 |
| 不能主动调用 | 你可以手动调(a.~Holder();),但几乎不该这么做——它会自动跑,手动调容易变成"释放两次" |
二、什么时候会自动调用
| 对象住在哪 | 什么时候析构 |
|---|---|
局部对象(函数里 Holder h;) |
离开作用域(函数返回)时 |
用 new 建的对象 |
delete 的时候(不用 delete 就不析构,内存也不还——内存泄漏) |
| 全局对象 / 静态对象 | 程序结束时(6.7.2 的 static) |
| 临时对象(如按值返回产生的) | 用完的那一刻 |
看一段最能说明问题的代码:
void f() {
Holder h(1, 10);
cout << "函数体里" << endl;
} // ← 出这个右括号的瞬间,h 的析构函数被调用
int main() {
cout << "调用 f 前" << endl;
f();
cout << "调用 f 后" << endl;
}
输出顺序:
调用 f 前
函数体里
析构 #10:释放 ... ← 注意位置:在"调用 f 后"之前
调用 f 后
关键点:析构发生在"离开作用域"的那一刻,不是程序结束的时候。 局部对象活得比你想象的短。
三、析构顺序:后造的,先走
同一个作用域里有多个对象时,析构顺序与构造顺序完全相反——像叠盘子(后放的先拿走)。
int main() {
Holder a(1, 10);
Holder b(2, 20);
Holder c(3, 30);
cout << "三兄弟都造好了" << endl;
}
输出:
构造 #10 ...
构造 #20 ...
构造 #30 ...
三兄弟都造好了
析构 #30 ... ← 先走 c
析构 #20 ... ← 再走 b
析构 #10 ... ← 最后走 a
为什么必须反过来? 因为后面的对象可能用到前面的对象(比如 c 里存了 a 的引用)——先送走后来的,才不会出现"用到的对象已经没了"的悬空问题。
和 6.3.2 的"嵌套调用先进后出"、6.3.3 的"递归栈"是同一个道理:栈的规矩——后进先出。
四、它到底用来干什么
没有申请资源的类,析构函数可以什么都不写(甚至可以不写这个函数)。它的价值,全在"要还东西"的时候:
| 对象借了什么 | 析构函数该做什么 |
|---|---|
new 出来的一块内存 |
delete(或 delete[]) |
| 打开的文件 | fclose() |
| 数据库连接 / 网络套接字 | 断开 |
| 加锁(多线程) | 解锁 |
构造函数和析构函数的"对称"关系:
| 构造函数 | 析构函数 | |
|---|---|---|
| 时机 | 出生时 | 临终时 |
| 名字 | 类名() |
~类名() |
| 数量 | 可以很多个(重载) | 只有一个 |
| 参数 | 可以有 | 不能有 |
| 典型工作 | 申请资源、设初值 | 归还资源(构造里申请了什么,这里就还什么) |
记住这条对称:在构造函数里
new了几次,就要在析构函数里delete几次。 少一次就是内存泄漏,多一次就是重复释放崩溃。
五、"三法则":三个函数要一起想
这是本节最重要的一条工程规矩:
如果一个类需要自己写析构函数(也就是"它拥有某种资源"),那么它通常也需要自己写复制构造函数和赋值运算符。
为什么:有资源要释放 = 成员里有指针,指向自己申请的内存。而 10.2.2 讲过——这种情况下编译器送的浅拷贝是个雷(两个对象指同一块内存 → 改一个动另一个、还重复释放)。
三个函数一起看才安全:
class Holder {
private:
int *data;
public:
Holder(int v); // ① 构造:申请
Holder(const Holder &o); // ② 复制构造:另存一份(10.2.2)
~Holder(); // ③ 析构:归还
};
只在析构里 delete 而不写复制构造函数,就会得到 10.2.2 第五节那个"同一地址释放两次"的现场。所以看到"析构函数里有 delete",就该顺手检查"复制构造函数写了没有"。
注意:"三法则"是经验规矩,不是编译器强制的。你不写复制构造函数,编译器照样给你编译通过——错误会在运行期以崩溃或数据错乱的形式出现,这正是它危险的地方。
六、实例03:使用析构函数
目标:造一个"管理一块内存"的对象,用它展示析构什么时候跑、跑的顺序、以及它如何保证资源被归还。
#include <iostream>
using namespace std;
class IntBox {
private:
int *data;
int tag;
public:
// ───── ① 构造:申请资源 ─────
IntBox(int v, int t) : data(new int(v)), tag(t) {
cout << "构造 #" << tag << ":申请到 " << data
<< ",值 " << *data << endl;
}
// ───── ② 复制构造:另存一份(配合 ③ 一起才安全)─────
IntBox(const IntBox &o) : data(new int(*o.data)), tag(o.tag + 100) {
cout << "复制构造(源 #" << o.tag << " → 新 #" << tag
<< "):申请到 " << data << endl;
}
// ───── ③ 析构:归还资源(实例03 的主角)─────
~IntBox() {
cout << "析构 #" << tag << ":归还 " << data << endl;
delete data;
data = nullptr; // 7.4.4 的好习惯:用完备空
}
void set(int v) { *data = v; }
int get() const { return *data; }
};
// 一个函数,用来观察"离开作用域"这一刻
void work() {
cout << " [work] 开始" << endl;
IntBox inner(50, 9);
cout << " [work] 里面用着值 " << inner.get() << endl;
cout << " [work] 即将返回" << endl;
} // ★ 右括号:inner 在这里析构
int main() {
cout << "=== 主函数开始 ===" << endl;
IntBox a(10, 1);
IntBox b(20, 2);
cout << "--- 调用 work() ---" << endl;
work();
cout << "--- work() 已返回 ---" << endl;
IntBox c(a); // 复制构造
cout << "--- 改 c 看 a 是否受影响 ---" << endl;
c.set(888);
cout << "a = " << a.get() << "(应仍为 10)" << endl;
cout << "c = " << c.get() << "(应为 888)" << endl;
cout << "=== 主函数即将结束 ===" << endl;
return 0;
}
看到什么算成功(地址数字会不同,注意每一对"申请/归还"都是同一地址、且没有重复):
=== 主函数开始 ===
构造 #1:申请到 0x1a1a20,值 10
构造 #2:申请到 0x1a1a40,值 20
--- 调用 work() ---
[work] 开始
构造 #9:申请到 0x1a1a60,值 50
[work] 里面用着值 50
[work] 即将返回
析构 #9:归还 0x1a1a60 ← ★ 出作用域就跑了
--- work() 已返回 ---
复制构造(源 #1 → 新 #101):申请到 0x1a1a80
--- 改 c 看 a 是否受影响 ---
a = 10(应仍为 10)
c = 888(应为 888)
=== 主函数即将结束 ===
析构 #101:归还 0x1a1a80
析构 #2:归还 0x1a1a40
析构 #1:归还 0x1a1a20
读结果四句话:
析构 #9出现在"work() 已返回"之前——局部对象在离开作用域那一刻就被销毁了,不是等程序结束。- 程序末尾三个析构的顺序是
#101→#2→#1——c最后造、最先走;a最先造、最后走。这就是"后进先出"。 - 每一条"归还"的地址,都对应前面某条"申请"的地址——一一抵消,不多不少。这就是"没有内存泄漏"的样子。
a = 10没被c影响——因为c有自己的内存块(复制构造函数干的活,10.2.2)。
30 秒实验:把 IntBox c(a); 改成 IntBox &c = a;(引用,不复制),再跑。你会发现输出里不再有"复制构造",而且末尾少了一次析构——因为没有新对象被造出来,c 只是 a 的第二个名字(7.8.1)。对比例子最能分清"复制"和"起别名"。
七、常见误区
- "析构函数可以重载,写好几个"——不行,只有一个。它没有参数,靠什么区分?一山不容二虎。
- "析构函数会释放对象占的内存"——不完全是。对象本身就是一块内存(成员变量那些字节),它由系统自动回收(局部对象在栈上、离开作用域自动回收)。析构函数负责的是"对象自己申请的那些额外资源"——比如
new出来的内存、打开的文件。这是最容易搞混的一处。 - "
new出来的对象,出了作用域会自动析构"——不会。new出来的对象住在堆上,必须手动delete;不delete,既不析构也不还内存(内存泄漏)。这是"栈对象"和"堆对象"最大的差别。 - "析构函数里可以随便抛异常"——不要。C++ 里"析构期间抛异常"是出了名的危险(程序可能直接终止)。析构里只做"安静地还东西"。
- "忘了写析构函数也没事"——没有资源要还就没事;有资源要还就是内存泄漏。所以写完构造函数顺手问一句:"我在这里申请了什么?析构函数里还了吗?"
自测三问(点开对答案)
1. 析构函数的名字、参数、数量各有什么规定?——名字是 **`~类名`**;**没有参数、没有返回类型**;**一个类只能有一个,不能重载**。 2. 局部对象的析构在什么时刻发生?析构顺序是什么规矩?——**离开作用域(函数返回)的那一刻**,不是程序结束的时候。**顺序与构造相反:后造的先销毁**(栈的"后进先出")。 3. 为什么说"有了析构函数,通常也要写复制构造函数"?——因为**要写析构,说明类里有"自己申请的资源"(指针成员)**;这时编译器送的**浅拷贝**会让两个对象指向同一块内存,导致"改一个动另一个"和"重复释放"。**这就是三法则。**- ☐ 10.4 类成员(下一节)
相关笔记
- 复制构造函数 —— 上一节:复制时另存一份
- 类成员 —— 下一节:类里面还有哪些机关
- 变量作用域 —— "离开作用域"到底是哪儿
- static变量 —— 静态对象活到程序结束
- 指向空的指针与空类型指针 —— 释放后置空的好习惯
- 🔷 C++基础 · 索引 —— 全课路线图