Skip to content

析构函数:对象走的时候,把资源一起带走 ​

构造函数负责把对象带入一个合法的状态:分配内存、打开文件、初始化硬件。但所有这些资源都有一个共同的问题:它们必须在某个时刻被归还。咱们要是 malloc 了不 free、fopen 了不 fclose、锁了互斥量不解锁,程序就会慢慢泄漏资源,最终耗尽系统配额或者陷入死锁。

C++ 解决这个问题的手段是析构函数(destructor),咱们靠它把资源的归还自动化。构造函数和析构函数形成了一组对称:一个在对象诞生时自动执行,另一个在对象死亡时自动执行。这种"构造时获取、析构时释放"的模式有专门的名字:RAII,它是 C++ 资源管理的基础。

咱们把析构函数从头到尾拆清楚:语法、调用时机、RAII 的核心思想,以及一个在设计上绕不开的经典准则:Rule of Three。

析构函数的语法 ​

咱们声明析构函数的方式非常简单:在类名前面加一个波浪号 ~,没有参数,没有返回类型。一个类只能有一个析构函数,不支持重载。

展开代码收起代码共 24 行
C++
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):

C++
void process() {
    FileWriter writer("log.txt", "w");
    writer.write("Processing started\n");
}   // writer 在这里析构,文件自动关闭

堆对象只有显式 delete 时才析构,这也是咱们遇到 C++ 资源泄漏的主要来源之一:

C++
void leaky() {
    FileWriter* writer = new FileWriter("log.txt", "w");
    writer->write("Oops\n");
    // 忘了 delete —— 析构不调用,文件永远不会关闭
}

new 出来的对象忘了 delete,析构函数永远不会执行。即使咱们记住了在正常路径上 delete,只要中间抛了异常,delete 就会被跳过。现代 C++ 强烈建议用智能指针或栈对象代替裸 new/delete。

成员对象的析构发生在包含类的析构函数体执行完毕之后,顺序与构造严格相反。咱们写一个小程序来验证:

展开代码收起代码共 33 行
C++
#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;
}

运行输出:

text
=== 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 行
C++
#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。

C++
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 —— 未定义行为!
}

咱们的修复方法之一是直接禁止拷贝:

C++
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 基类指针时,派生类的析构函数会被完全跳过。

C++
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:

C++
class Base {
public:
    virtual ~Base() { std::cout << "~Base" << std::endl; }
};

这条规则的适用条件是类会被当作多态基类使用。安全经验是:咱们的类只要有 virtual 函数,析构函数就应该是 virtual 的。反过来,没有 virtual 函数的类不需要虚析构,加了反而给每个对象增加虚函数表指针的开销。这个话题咱们下一章讲继承和多态时再深入展开。

实战:析构函数在行动 ​

现在咱们动手写一段完整代码,把 ScopedTimer 和 FileWriter 串起来演示 RAII 的实际效果:

展开代码收起代码共 72 行
C++
// 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;
}

编译运行:

bash
g++ -std=c++17 -o destructor destructor.cpp && ./destructor

输出:

text
--- 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 最后析构。您可以验证文件内容:

bash
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 行
C++
#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;
}

编译运行:

bash
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main

运行结果:

text
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 行
C++
#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:

text
Hello from line 1
Hello from line 2

编译运行:

bash
g++ -std=c++17 -Wall -Wextra main.cpp -o main && ./main

运行结果:

text
Hello from line 1
Hello from line 2

关于练习里的追问:这个类需要禁用拷贝。FileHandle 持有裸 FILE* 资源,编译器默认生成的拷贝操作只做浅拷贝:两个对象的 handle 指向同一个 FILE*,作用域结束时会对同一个句柄 fclose 两次,属于未定义行为。这正是本章 Rule of Three 的应用场景:咱们手写了析构函数,就必须同时审视拷贝语义——要么实现深拷贝,要么像这里一样直接禁止拷贝。

pdf-latest-4-g85128cc · 85128cc · 2026-10-05