对象有被创建出来的那一刻,自然也有使用完毕、被销毁的时候。对象创建时由构造函数负责初始化,而当对象即将被销毁时,专门负责收尾清理工作的特殊成员函数就叫做 “析构函数(Destructor)”。
和构造函数一样,析构函数也不需要我们手动调用。一个对象从创建到销毁的存活时间称为 “生命周期”,一旦对象的生命周期结束、即将被销毁,编译器就会自动执行析构函数。
C++ 析构函数的基本用法
在 C++ 中,析构函数的写法和无参构造函数非常相似,只是在函数名前面多加了一个英文波浪号(~)。
语法:
class 类名 {
public:
~类名() {
// 清理代码
}
};说明:
编写析构函数时,我们需要遵循以下 4 条语法规则:
- 函数名固定:析构函数的名字是在类名前面加上波浪号,也就是写成
~类名。比如FileLogger类的析构函数名就是~FileLogger。 - 没有返回值类型:和构造函数一样,析构函数前面不能写任何返回值类型,连
void都不能写。 - 不能带任何参数:析构函数的小括号
()内部必须为空,不能接收任何参数。正因为没有参数,析构函数不支持函数重载,一个类中最多只能有一个析构函数。 - 对象销毁时自动调用:当对象离开所在的作用域、即将被销毁时,C++ 编译器会自动调用析构函数。
为了直观看到析构函数是在什么时候自动执行的,我们可以定义一个模拟日志记录的 FileLogger 类,在构造函数和析构函数中各打印一句提示文字。下面我们来看一个例子。
示例 1:析构函数的自动执行
#include <iostream>
#include <string>
class FileLogger {
private:
std::string fileName;
public:
// 构造函数
FileLogger(std::string name) : fileName(name) {
std::cout << "打开日志文件:" << fileName << '\n';
}
// 析构函数
~FileLogger() {
std::cout << "关闭日志文件:" << fileName << '\n';
}
};
int main() {
FileLogger logger("app.log");
std::cout << "正在写入运行日志...\n";
return 0;
}运行结果如下。
打开日志文件:app.log
正在写入运行日志...
关闭日志文件:app.log分析:
在这个例子中,当我们在 main() 函数中执行 FileLogger logger("app.log"); 创建对象时,构造函数会立刻自动执行,先打印出打开日志文件的提示。随后程序继续往下执行,输出 “正在写入运行日志...”。
等执行完 return 0;、程序即将离开 main() 函数的右花括号 } 时,局部对象 logger 的生命周期结束。此时编译器自动调用了 ~FileLogger() 析构函数,输出最后一行关闭提示。
可能有些小伙伴会问:“既然只是做清理工作,我们自己在 main() 末尾写一行关闭代码不就行了吗?” 其实原因很简单。如果全靠我们手动写清理代码,万一函数在中途提前 return 退出了,或者一时粗心漏写了,占用的文件或内存就会一直无法释放。
而析构函数就像酒店退房时的 “自动断电机制” 一样。不管函数是从末尾正常结束,还是在中途提前退出,只要对象离开它所在的花括号 {},编译器都会保证自动执行一次析构函数。
析构函数的调用时机与顺序
对于在函数内部定义的普通局部对象来说,判断它什么时候析构非常简单:只要程序执行离开定义该对象的那一对花括号 {},这个对象就会立刻被销毁,并自动调用析构函数。
那么,如果同一个作用域里先后创建了多个对象,或者一个类的内部还包含其他成员对象,销毁时谁先析构、谁后析构呢?下面我们分 2 种情况来看。
1. 多个局部对象的析构顺序
在函数内部,我们也可以单独使用一对花括号 {} 划分出一个局部作用域。下面我们通过一个例子,观察多个局部对象的构造与析构顺序。
示例 2:局部对象的析构顺序
#include <iostream>
#include <string>
class Task {
private:
std::string name;
public:
Task(std::string n) : name(n) {
std::cout << "创建:" << name << '\n';
}
~Task() {
std::cout << "销毁:" << name << '\n';
}
};
int main() {
Task t1("任务 1"); // 外层作用域对象
{
Task t2("任务 2"); // 内层第 1 个对象
Task t3("任务 3"); // 内层第 2 个对象
} // 离开内层花括号
std::cout << "已离开内部作用域\n";
return 0;
}运行结果如下。
创建:任务 1
创建:任务 2
创建:任务 3
销毁:任务 3
销毁:任务 2
已离开内部作用域
销毁:任务 1分析:
从运行结果中,我们可以看出以下 2 个规律:
- 离开所在花括号即立刻销毁:
t2和t3定义在内部那对花括号{}中,一旦程序执行到内部的右花括号},它俩就会立刻析构销毁。而t1定义在main()的外层花括号中,要等到main()结束时才会被销毁。 - 同一作用域内先构造的后析构:在内部作用域中,
t2比t3先创建,但销毁时却是t3先析构、t2后析构,顺序正好相反。
为什么同一作用域里析构的顺序是反过来的呢?这就像往单行死胡同里停车一样:最先开进去的车停在最里面,最后开进去的车堵在最外面,如下图所示。等到倒车出来时,自然得让最外面的车先退出来,最后才轮到最里面的车。

在下面这段代码中,3 个对象被销毁的先后顺序依次是 b、c、a,小伙伴们可以思考一下为什么。
Task a("A");
{
Task b("B");
}
Task c("C");2. 包含成员对象时的构造与析构顺序
一个类可以把另一个类的对象作为自己的成员变量,比如汽车类 Car 内部包含一个发动机类 Engine 的对象。上一节学习初始化列表时,我们就接触过这种 “成员对象”。
在创建和销毁这类外层对象时,它们的执行顺序同样符合日常生活常识。要想组装出一辆完整的汽车 Car,肯定得先把里面的发动机 Engine 造好。而当汽车报废拆解时,则是先把外层车壳拆掉,最后才拆解里面的发动机。具体规律如下:
- 构造顺序(先内后外):先执行内部 “成员对象” 的构造函数,再执行 “外层类自己” 的构造函数。
- 析构顺序(先外后内):先执行 “外层类自己” 的析构函数,再执行内部 “成员对象” 的析构函数。
下面我们结合具体示例来看。
示例 3:成员对象的析构顺序
#include <iostream>
class Engine {
public:
Engine() {
std::cout << "Engine 构造\n";
}
~Engine() {
std::cout << "Engine 析构\n";
}
};
class Car {
private:
Engine engine; // 成员对象
public:
Car() {
std::cout << "Car 构造\n";
}
~Car() {
std::cout << "Car 析构\n";
}
};
int main() {
Car myCar;
return 0;
}运行结果如下。
Engine 构造
Car 构造
Car 析构
Engine 析构分析:
当我们执行 Car myCar; 创建外层对象时,它的内部成员对象 engine 必须先在进入 Car 构造函数体之前构造完成。因此,屏幕上会先输出 “Engine 构造”,再输出 “Car 构造”。
等 main() 函数结束、myCar 即将销毁时,执行顺序正好反过来。程序会先执行外层 Car 自己的析构函数输出 “Car 析构”,最后才销毁内部的成员对象 engine,输出 “Engine 析构”。
提示 如果一个类中同时包含多个成员对象(如既有 Engine 又有 Wheel),它们之间的构造顺序只按照在类中声明的先后顺序执行,析构时则按相反的顺序依次销毁。
析构函数的使用原则
在前面的例子中,我们在析构函数里打印文字,只是为了方便小伙伴们直观看到它的执行时机。在实际项目开发中,析构函数真正的职责是释放对象在运行期间占用的外部资源,比如关闭已打开的文件、断开网络连接,或者配合后面要学习的 new 和 delete 释放动态内存。
平时编写类时,我们只需要把握以下 2 点原则:
- 简单类不需要手写析构函数:如果我们没有手写析构函数,编译器会自动生成一个默认的空析构函数。对于只包含
int、double、std::string等普通成员的简单类,默认析构函数就已经够用了,不需要我们再去手写一个空函数。 - 不要手动调用局部对象的析构函数:局部对象离开花括号
{}时,编译器本来就会自动调用一次析构函数。如果我们提前手动调用了一次,等离开作用域时同一个对象就会被重复销毁两次,直接导致程序崩溃。
对于第 2 点,大家平时写代码时千万不要写出下面这种手动调用析构函数的语句。
FileLogger logger("app.log");
logger.~FileLogger();