🔷 第十章 类和对象
📄 一句话概括

析构函数是"对象临终时自动执行的那个函数"——构造函数管"进门手续",它管"出门善后"。长相是 ~类名()(类名前一个波浪号),没有参数、没有返回类型、不能重载(一个类只能有一个)。它的主要任务是把对象借用的资源还回去(释放内存、关文件、断开连接)。析构顺序和构造顺序正好相反:后造出来的先销毁。

📝 本节和前面几节的关系

这是"对象一生"的另一个端点: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

读结果四句话:

  1. 析构 #9 出现在"work() 已返回"之前——局部对象在离开作用域那一刻就被销毁了,不是等程序结束。
  2. 程序末尾三个析构的顺序是 #101 → #2 → #1——c 最后造、最先走;a 最先造、最后走。这就是"后进先出"。
  3. 每一条"归还"的地址,都对应前面某条"申请"的地址——一一抵消,不多不少。这就是"没有内存泄漏"的样子。
  4. a = 10 没被 c 影响——因为 c 有自己的内存块(复制构造函数干的活,10.2.2)。

30 秒实验:把 IntBox c(a); 改成 IntBox &c = a;(引用,不复制),再跑。你会发现输出里不再有"复制构造",而且末尾少了一次析构——因为没有新对象被造出来,c 只是 a 的第二个名字(7.8.1)。对比例子最能分清"复制"和"起别名"。

七、常见误区

  1. "析构函数可以重载,写好几个"——不行,只有一个。它没有参数,靠什么区分?一山不容二虎。
  2. "析构函数会释放对象占的内存"——不完全是。对象本身就是一块内存(成员变量那些字节),它由系统自动回收(局部对象在栈上、离开作用域自动回收)。析构函数负责的是"对象自己申请的那些额外资源"——比如 new 出来的内存、打开的文件。这是最容易搞混的一处。
  3. "new 出来的对象,出了作用域会自动析构"——不会。new 出来的对象住在堆上,必须手动 delete;不 delete,既不析构也不还内存(内存泄漏)。这是"栈对象"和"堆对象"最大的差别。
  4. "析构函数里可以随便抛异常"——不要。C++ 里"析构期间抛异常"是出了名的危险(程序可能直接终止)。析构里只做"安静地还东西"。
  5. "忘了写析构函数也没事"——没有资源要还就没事;有资源要还就是内存泄漏。所以写完构造函数顺手问一句:"我在这里申请了什么?析构函数里还了吗?"
自测三问(点开对答案) 1. 析构函数的名字、参数、数量各有什么规定?——名字是 **`~类名`**;**没有参数、没有返回类型**;**一个类只能有一个,不能重载**。 2. 局部对象的析构在什么时刻发生?析构顺序是什么规矩?——**离开作用域(函数返回)的那一刻**,不是程序结束的时候。**顺序与构造相反:后造的先销毁**(栈的"后进先出")。 3. 为什么说"有了析构函数,通常也要写复制构造函数"?——因为**要写析构,说明类里有"自己申请的资源"(指针成员)**;这时编译器送的**浅拷贝**会让两个对象指向同一块内存,导致"改一个动另一个"和"重复释放"。**这就是三法则。**

相关笔记