析构函数:对象走的时候,把资源一起带走
构造函数负责把对象带入一个合法的状态:分配内存、打开文件、初始化硬件。但所有这些资源都有一个共同的问题:它们必须在某个时刻被归还。咱们要是 malloc 了不 free、fopen 了不 fclose、锁了互斥量不解锁,程序就会慢慢泄漏资源,最终耗尽系统配额或者陷入死锁。
C++ 解决这个问题的手段是析构函数(destructor),咱们靠它把资源的归还自动化。构造函数和析构函数形成了一组对称:一个在对象诞生时自动执行,另一个在对象死亡时自动执行。这种"构造时获取、析构时释放"的模式有专门的名字:RAII,它是 C++ 资源管理的基础。
咱们把析构函数从头到尾拆清楚:语法、调用时机、RAII 的核心思想,以及一个在设计上绕不开的经典准则:Rule of Three。
析构函数的语法
咱们声明析构函数的方式非常简单:在类名前面加一个波浪号 ~,没有参数,没有返回类型。一个类只能有一个析构函数,不支持重载。
展开代码收起代码共 24 行
class FileWriter {
private:
FILE* file_handle;
public:
FileWriter(const char* path, const char* mode)
: file_handle(std::fopen(path, mode))
{
if (file_handle == nullptr) {
std::cerr << "Failed to open: " << path << std::endl;
}
}
~FileWriter() {
if (file_handle != nullptr) {
std::fclose(file_handle);
std::cout << "File closed by destructor" << std::endl;
}
}
void write(const char* data) {
if (file_handle) { std::fputs(data, file_handle); }
}
};析构函数不能接受参数,不可能重载;也没有返回值。这些限制咱们不难理解:析构函数由运行时自动调用,调用者不需要传递任何东西。
咱们要是没定义析构函数,编译器会生成一个默认版本,按成员声明的逆序析构非静态成员。只包含基本类型的类不需要手写析构,但如果类管理了外部资源(动态内存、文件句柄、网络连接),咱们就必须自己写析构函数来释放它们。(这很正常,因为编译器也不知道咱们要如何释放自己的资源)
析构函数什么时候被调用
理解调用时机是咱们正确使用 RAII 的前提。栈对象在离开作用域时自动析构,无论正常返回、提前 return 还是异常展开(stack unwinding):
void process() {
FileWriter writer("log.txt", "w");
writer.write("Processing started\n");
} // writer 在这里析构,文件自动关闭堆对象只有显式 delete 时才析构,这也是咱们遇到 C++ 资源泄漏的主要来源之一:
void leaky() {
FileWriter* writer = new FileWriter("log.txt", "w");
writer->write("Oops\n");
// 忘了 delete —— 析构不调用,文件永远不会关闭
}new 出来的对象忘了 delete,析构函数永远不会执行。即使咱们记住了在正常路径上 delete,只要中间抛了异常,delete 就会被跳过。现代 C++ 强烈建议用智能指针或栈对象代替裸 new/delete。
成员对象的析构发生在包含类的析构函数体执行完毕之后,顺序与构造严格相反。咱们写一个小程序来验证:
展开代码收起代码共 33 行
#include <iostream>
struct Tracer {
const char* name;
explicit Tracer(const char* n) : name(n) {
std::cout << " [" << name << "] constructed" << std::endl;
}
~Tracer() {
std::cout << " [" << name << "] destructed" << std::endl;
}
};
struct Container {
Tracer member_a;
Tracer member_b;
Container() : member_a("member_a"), member_b("member_b") {
std::cout << " [Container] ctor body" << std::endl;
}
~Container() {
std::cout << " [Container] dtor body" << std::endl;
}
};
int main() {
std::cout << "=== begin ===" << std::endl;
{
Tracer local("local");
Container container;
Tracer* heap = new Tracer("heap");
delete heap;
}
std::cout << "=== end ===" << std::endl;
}运行输出:
=== begin ===
[local] constructed
[member_a] constructed
[member_b] constructed
[Container] ctor body
[heap] constructed
[heap] destructed
[Container] dtor body
[member_b] destructed
[member_a] destructed
[local] destructed
=== end ===咱们看构造顺序是 local -> member_a -> member_b -> Container body,析构严格反过来——"后构造的先析构"保证了资源以正确的层次被释放。
RAII——C++ 资源管理的核心思想
RAII 全称 Resource Acquisition Is Initialization,咱们把核心理念记成一句话:把资源的生命周期绑定到对象的生命周期上。构造时获取资源,析构时释放资源。因为析构函数在对象离开作用域时一定会被调用(即使发生异常),资源一定会被正确释放。
咱们来看一个实用的例子:测量代码块执行时间的 ScopedTimer:
展开代码收起代码共 32 行
#include <chrono>
#include <iostream>
class ScopedTimer {
private:
const char* label_;
std::chrono::steady_clock::time_point start_;
public:
explicit ScopedTimer(const char* label)
: label_(label), start_(std::chrono::steady_clock::now())
{
std::cout << "[" << label_ << "] started" << std::endl;
}
~ScopedTimer() {
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - start_);
std::cout << "[" << label_ << "] elapsed: "
<< us.count() << " us" << std::endl;
}
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
};
void heavy_computation() {
ScopedTimer timer("heavy_computation");
for (int i = 0; i < 1000000; ++i) {
volatile int x = i * i;
}
} // timer 在这里析构,自动打印耗时咱们不需要记住在函数末尾"停止计时器"——析构函数自动替咱们完成。多条 return 路径、异常,每一条路径上计时器都会被正确销毁。这就是 RAII 的威力:它让"不漏"成为默认行为,而不是需要靠纪律维持的"记住去做"。
RAII 的前提是对象必须活在栈上(或者全局/静态对象),而不是裸 new 出来的堆对象。咱们要是 new 了一个 RAII 对象却忘了 delete,析构函数照样不会调用——RAII 救不了咱们。现代 C++ 的建议是:尽量让对象活在栈上,如果必须用堆,就用智能指针。
Rule of Three——一个设计预警信号
Rule of Three(三之法则)是一个经典的设计准则:咱们的类要是需要自定义以下三者中的任何一个,几乎一定需要同时自定义另外两个,即析构函数、拷贝构造函数、拷贝赋值运算符。
这三个函数共同决定了对象"怎么被复制"和"怎么被销毁"。咱们写了析构函数,通常意味着类管理着需要手动释放的资源,而编译器默认生成的拷贝操作只做浅拷贝:指针成员被复制后,两个对象指向同一块资源,析构时 double free。
class NaiveBuffer {
int* data_;
std::size_t size_;
public:
explicit NaiveBuffer(std::size_t n) : size_(n), data_(new int[n]()) {}
~NaiveBuffer() { delete[] data_; }
// 没有自定义拷贝——编译器生成的版本做浅拷贝
};
void bug_demo() {
NaiveBuffer a(10);
NaiveBuffer b = a; // 浅拷贝:b.data_ == a.data_
// 作用域结束时 double free —— 未定义行为!
}咱们的修复方法之一是直接禁止拷贝:
class SafeBuffer {
int* data_;
std::size_t size_;
public:
explicit SafeBuffer(std::size_t n) : size_(n), data_(new int[n]()) {}
~SafeBuffer() { delete[] data_; }
SafeBuffer(const SafeBuffer&) = delete;
SafeBuffer& operator=(const SafeBuffer&) = delete;
};这里咱们先预览一下概念。等到讲完移动语义之后,Rule of Three 会扩展成 Rule of Five。目前您只需要记住:一旦手写了析构函数,就停下来想一下咱们的类能不能被安全地拷贝,如果不能,就把它删掉。
虚析构函数——多态的隐形陷阱
咱们的类如果会被继承,而且使用者通过基类指针操作派生类对象,那么基类的析构函数必须是 virtual 的。否则 delete 基类指针时,派生类的析构函数会被完全跳过。
class Base {
public:
~Base() { std::cout << "~Base" << std::endl; } // 非 virtual!
};
class Derived : public Base {
int* resource_;
public:
Derived() : resource_(new int[100]) {}
~Derived() { delete[] resource_; std::cout << "~Derived" << std::endl; }
};
void leak_demo() {
Base* ptr = new Derived();
delete ptr; // 只调用 ~Base(),~Derived() 被跳过 → 内存泄漏
}咱们看到输出只有 ~Base——resource_ 指向的 400 字节内存悄无声息地泄漏了。修复只需在基类析构函数前加 virtual:
class Base {
public:
virtual ~Base() { std::cout << "~Base" << std::endl; }
};这条规则的适用条件是类会被当作多态基类使用。安全经验是:咱们的类只要有 virtual 函数,析构函数就应该是 virtual 的。反过来,没有 virtual 函数的类不需要虚析构,加了反而给每个对象增加虚函数表指针的开销。这个话题咱们下一章讲继承和多态时再深入展开。
实战:析构函数在行动
现在咱们动手写一段完整代码,把 ScopedTimer 和 FileWriter 串起来演示 RAII 的实际效果:
展开代码收起代码共 72 行
// destructor.cpp
// 编译:g++ -std=c++17 -o destructor destructor.cpp
#include <chrono>
#include <cstdio>
#include <iostream>
/// @brief 作用域计时器
class ScopedTimer {
const char* label_;
std::chrono::steady_clock::time_point start_;
public:
explicit ScopedTimer(const char* label)
: label_(label), start_(std::chrono::steady_clock::now())
{ std::cout << "[" << label_ << "] started" << std::endl; }
~ScopedTimer() {
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - start_);
std::cout << "[" << label_ << "] finished: "
<< us.count() << " us" << std::endl;
}
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
};
/// @brief 自动管理 FILE* 的文件写入器
class FileWriter {
FILE* handle_;
const char* path_;
public:
FileWriter(const char* path, const char* mode)
: handle_(std::fopen(path, mode)), path_(path)
{
if (!handle_) std::cerr << "Error: cannot open " << path << std::endl;
}
~FileWriter() {
if (handle_) {
std::fclose(handle_);
std::cout << "[" << path_ << "] closed" << std::endl;
}
}
void write_line(const char* text) {
if (handle_) { std::fputs(text, handle_); std::fputc('\n', handle_); }
}
FileWriter(const FileWriter&) = delete;
FileWriter& operator=(const FileWriter&) = delete;
};
int main() {
std::cout << "--- RAII demo ---" << std::endl;
ScopedTimer total("total");
{
ScopedTimer phase("phase 1: file writing");
FileWriter writer("raii_demo.txt", "w");
writer.write_line("Hello from RAII!");
writer.write_line("No manual fclose needed.");
}
{
ScopedTimer phase("phase 2: computation");
volatile int sum = 0;
for (int i = 0; i < 1000000; ++i) { sum += i; }
}
std::cout << "--- end of main ---" << std::endl;
return 0;
}编译运行:
g++ -std=c++17 -o destructor destructor.cpp && ./destructor输出:
--- RAII demo ---
[total] started
[phase 1: file writing] started
[raii_demo.txt] closed
[phase 1: file writing] finished: 123 us
[phase 2: computation] started
[phase 2: computation] finished: 4567 us
--- end of main ---
[total] finished: 4789 us内层的 ScopedTimer 和 FileWriter 先析构,外层 total 最后析构。您可以验证文件内容:
cat raii_demo.txt
# Hello from RAII!
# No manual fclose needed.内容正确,咱们没有手写 fclose,析构函数替咱们完成了全部清理。
练习
练习 1:作用域日志计时器
请您写一个 ScopedLogger 类,构造时记录时间戳(格式 HH:MM:SS),析构时打印"elapsed X seconds"。提示:使用 <ctime> 中的 std::time 和 std::localtime。
参考答案
展开代码收起代码共 48 行
#include <chrono>
#include <ctime>
#include <iomanip>
#include <iostream>
#include <thread>
class ScopedLogger
{
private:
std::time_t start_time_;
public:
// 构造:记录开始时间戳
ScopedLogger()
{
start_time_ = std::time(nullptr);
std::tm* local_time = std::localtime(&start_time_);
std::cout << "Start time: "
<< std::setfill('0') << std::setw(2) << local_time->tm_hour
<< ":" << std::setw(2) << local_time->tm_min
<< ":" << std::setw(2) << local_time->tm_sec
<< std::endl;
}
// 析构:打印从构造到析构经过的秒数
~ScopedLogger()
{
std::cout << "elapsed "
<< (std::time(nullptr) - start_time_)
<< " seconds"
<< std::endl;
}
// 禁止拷贝
ScopedLogger(const ScopedLogger&) = delete;
ScopedLogger& operator=(const ScopedLogger&) = delete;
};
int main()
{
ScopedLogger logger;
// 休眠 2 秒,模拟一段被计时的代码
std::this_thread::sleep_for(std::chrono::seconds(2));
return 0;
}编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main运行结果:
Start time: 11:17:43
elapsed 2 seconds小技巧:
std::setfill('0')配合std::setw(2)可以让时分秒在不足两位时自动补0(比如 9 时 5 分 3 秒会显示成09:05:03)。其中setfill设置的填充字符会一直保留在流上,写一次即可;而setw只对紧随其后的一个输出生效,每个字段都要重新设置。另外,Start time取决于运行时的本地时间,您跑出来的结果和这里不一定相同。
练习 2:简易文件句柄
请您实现一个 FileHandle 类,构造时打开文件,析构时自动关闭。提供 read_line() 方法(返回 std::string)和 is_valid() 方法。用 Rule of Three 的思路想想:这个类需要禁用拷贝吗?为什么?
参考答案
展开代码收起代码共 68 行
#include <cstdio>
#include <iostream>
#include <string>
class FileHandle
{
private:
FILE *handle;
public:
// 构造:打开文件
FileHandle(const char *filename)
{
handle = fopen(filename, "r");
}
// 析构:关闭文件
~FileHandle()
{
if (handle != nullptr)
{
fclose(handle);
}
}
// 读取一行
std::string read_line()
{
char buffer[256];
if (fgets(buffer, sizeof(buffer), handle) != nullptr)
{
return std::string(buffer);
}
return {};
}
// 判断文件是否打开成功
bool is_valid() const
{
return handle != nullptr;
}
// 禁止拷贝
FileHandle(const FileHandle &) = delete;
FileHandle &operator=(const FileHandle &) = delete;
};
int main()
{
FileHandle file("test.txt");
// 判断文件是否打开成功
if (!file.is_valid())
{
std::cout << "open file failed" << std::endl;
return 1;
}
// 读取第一行
std::cout << file.read_line();
// 读取第二行
std::cout << file.read_line();
return 0;
}咱们准备一个测试用的 test.txt:
Hello from line 1
Hello from line 2编译运行:
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main运行结果:
Hello from line 1
Hello from line 2关于练习里的追问:这个类需要禁用拷贝。
FileHandle持有裸FILE*资源,编译器默认生成的拷贝操作只做浅拷贝:两个对象的handle指向同一个FILE*,作用域结束时会对同一个句柄fclose两次,属于未定义行为。这正是本章 Rule of Three 的应用场景:咱们手写了析构函数,就必须同时审视拷贝语义——要么实现深拷贝,要么像这里一样直接禁止拷贝。