C++ 析构函数完全指南:对象销毁背后的秘密

很多人学 C++ 时,注意力都放在构造函数上——怎么创建对象、怎么初始化。但析构函数同样重要,甚至更重要。构造函数决定了对象怎么"生",析构函数决定了对象怎么"死"。死得不好,轻则内存泄漏,重则程序崩溃,而且这些 bug 往往藏得很深。

今天我们就深入剖析析构函数的一切。

1. 析构函数是什么?

析构函数是对象生命周期结束时自动调用的特殊成员函数,负责清理资源。

class FileHandler {
    FILE* file;
public:
    FileHandler(const char* filename) {
        file = fopen(filename, "r");
    }
    ~FileHandler() {  // 析构函数
        if (file) fclose(file);
    }
};

特点

  • 函数名是 ~类名
  • 没有返回类型
  • 没有参数,不能重载(一个类只有一个析构函数)
  • 对象销毁时自动调用,也可以手动调用(极少需要)

2. 析构函数什么时候被调用?

2.1 自动变量离开作用域

void func() {
    FileHandler fh("data.txt");  // 构造
    // ...
}  // 离开作用域,fh 的析构函数自动调用

2.2 动态分配的对象被 delete

FileHandler* p = new FileHandler("data.txt");
delete p;  // 析构函数在这里调用

2.3 容器中的对象被销毁

{
    std::vector<FileHandler> files;
    files.emplace_back("a.txt");
    files.emplace_back("b.txt");
}  // vector 销毁时,内部所有元素的析构函数都会被调用

2.4 临时对象

FileHandler("temp.txt");  // 临时对象,这条语句结束后立即析构

注意:手动调用析构函数并不导致对象被销毁

2.5 派生类对象销毁时

基类和派生类的析构函数都会被调用,顺序与构造相反:先析构派生类部分,再析构基类部分。

3. 析构函数的核心原则

3.1 永远不要让析构函数抛异常

这是 C++ 铁律。如果析构函数抛出异常且未被捕获,程序会直接 std::terminate

class Bad {
public:
    ~Bad() {
        throw std::runtime_error("error");  // 危险!
    }
};

// 如果 Bad 对象在异常展开(stack unwinding)时被销毁
// 同时有两个异常传播,程序直接崩

正确做法:析构函数内部捕获所有异常:

class Safe {
public:
    ~Safe() noexcept {  // C++11 起析构函数默认 noexcept
        try {
            // 可能抛异常的操作
        } catch (...) {
            // 记录日志或吞掉异常
        }
    }
};

注意:C++11 起,析构函数隐式地是 noexcept,这是编译器层面的保障。

3.2 基类析构函数必须是虚的

这是 C++ 最经典的陷阱之一:

class Base {
public:
    ~Base() { std::cout << "~Base\n"; }  // 非虚析构!
};

class Derived : public Base {
    int* data;
public:
    Derived() : data(new int[1000]) {}
    ~Derived() { 
        delete[] data; 
        std::cout << "~Derived\n";
    }
};

Base* p = new Derived();
delete p;  
// 输出:~Base
// 没有 ~Derived!Derived 的 data 泄漏了!

当用基类指针 / 引用指向派生类对象时,如果基类析构函数不是虚函数,只会调用基类析构函数,派生类的析构函数完全不执行,会造成严重的内存泄漏。
加上 virtual 就好了

class Base {
public:
    virtual ~Base() { std::cout << "~Base\n"; }
};

delete p;
// 输出:~Derived
//       ~Base
// 析构顺序正确,资源正确释放

准则:只要一个类可能作为基类,析构函数就应该是虚的。如果类不会被继承,可以用 final 显式说明。

3.3 纯虚析构函数

有时候我们需要一个抽象基类,但没有其他合适的纯虚函数。这时可以让析构函数成为纯虚函数:

class AbstractBase {
public:
    virtual ~AbstractBase() = 0;  // 纯虚析构
};

// 但必须提供定义!
AbstractBase::~AbstractBase() {}  // 在类外提供定义

为什么需要定义?因为派生类析构时会调用基类析构,如果基类析构没有定义,链接器会报错。

4. 虚析构函数的实现原理

虚析构函数通过虚函数表(vtable) 实现:

  1. 基类有虚析构函数 → 类有 vtable
  2. 对象有 vptr 指向 vtable
  3. delete 基类指针时,通过 vptr 找到正确的析构函数(派生类的析构函数)
  4. 派生类析构执行完后,自动调用基类析构
// delete p 大致等价于:
// p->vptr[析构函数槽位]()  调用派生类析构
// 派生类析构自动调用基类析构

5. 虚构与三/五法则

还记得上一篇文章中的三/五法则吗?析构函数是关键信号:

如果你定义了一个析构函数(说明你管理了某种资源),那么大概率你也需要定义:

  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数(C++11)
  • 移动赋值运算符(C++11)
class MyArray {
    int* data;
    size_t size;
public:
    MyArray(size_t s) : data(new int[s]), size(s) {}
    ~MyArray() { delete[] data; }  // 有析构 → 需要拷贝和移动!
    
    // 如果不写下面这些,默认的拷贝只会复制指针,导致 double free
    MyArray(const MyArray& other) : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }
    MyArray& operator=(const MyArray& other) { /* 深拷贝 */ return *this; }
    MyArray(MyArray&& other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }
    MyArray& operator=(MyArray&& other) noexcept { /* 移动交换 */ return *this; }
};

零法则则反过来:如果你用 std::vectorstd::stringstd::unique_ptr 等 RAII 类型管理资源,你不需要写析构函数,编译器生成的默认版本就是对的。

class Good {
    std::vector<int> data;       // 自动管理内存
    std::unique_ptr<Config> cfg; // 自动释放
    // 不需要析构函数,编译器生成的足够
};

6. RAII:C++ 资源管理的基石

RAII(Resource Acquisition Is Initialization)是析构函数最重要的应用模式:

在构造函数中获取资源,在析构函数中释放资源。

// 互斥锁自动管理
class LockGuard {
    std::mutex& mtx;
public:
    LockGuard(std::mutex& m) : mtx(m) { mtx.lock(); }
    ~LockGuard() { mtx.unlock(); }  // 离开作用域自动解锁
};

// 使用
void threadSafeFunc() {
    LockGuard lock(sharedMutex);
    // 临界区代码
    // ...
}  // lock 析构,自动解锁,即使发生异常也会解锁

RAII 保证了异常安全——即使函数因为异常提前退出,栈展开过程也会调用析构函数,资源不会泄漏。

标准库中的 RAII 例子

  • std::unique_ptr / std::shared_ptr:智能指针,自动释放内存
  • std::lock_guard / std::unique_lock:自动管理锁
  • std::ifstream / std::ofstream:自动关闭文件
  • std::string / std::vector:自动管理内存

7. 继承体系中的析构顺序

class Base {
public:
    Base()  { std::cout << "Base()\n"; }
    virtual ~Base() { std::cout << "~Base()\n"; }
};

class Member {
public:
    Member()  { std::cout << "Member()\n"; }
    ~Member() { std::cout << "~Member()\n"; }
};

class Derived : public Base {
    Member m;
public:
    Derived()  { std::cout << "Derived()\n"; }
    ~Derived() { std::cout << "~Derived()\n"; }
};

Derived d;
// 输出:
// Base()      构造顺序:基类 → 成员 → 派生类
// Member()
// Derived()
// ~Derived()  析构顺序:派生类 → 成员 → 基类(严格相反)
// ~Member()
// ~Base()

构造顺序:基类 → 成员(按声明顺序)→ 构造函数体
析构顺序:析构函数体 → 成员(逆声明顺序)→ 基类

这是严格的反序,保证不会有任何依赖问题。

8. 手动调用析构函数

placement new 场景下,需要手动调用析构函数(但不释放内存):

// 原始内存缓冲区
alignas(Widget) char buffer[sizeof(Widget)];

// placement new:在 buffer 上构造 Widget
Widget* w = new(buffer) Widget();

// 使用 w...

// 手动调用析构(没有对应的 delete,因为内存不是 new 分配的)
w->~Widget();

手动调用析构函数的使用场景
语法对象.~类名(); / 指针->~类名();
核心前提不释放内存,只执行析构逻辑,不会调用operator delete

必须手动调用析构的4大场景

1. 内存池/自定义内存分配(最常用)

placement new已开好的原始内存上构造对象,不能用delete销毁,只能手动析构。

char buf[1024];
// 原地构造对象
A* p = new(buf)A();

// 1. 先手动调用析构
p->~A();
// 2. 再手动释放内存(无需delete)

原因:placement new 只构造不分配堆内存,delete会错误释放栈/池内存。

2. 容器复用内存、对象复用

游戏引擎、高性能框架中复用对象内存,只销毁状态,不释放空间:

  • 清空对象资源(文件、句柄、动态数组)
  • 保留内存地址,后续直接原地构造新对象

3. 联合体(union) 内含非平凡析构成员

C++17后联合体可以存带析构的类,无法自动调用析构,必须手动调用。

4. 泛型模板、模板元编程

模板中统一处理任意类型销毁逻辑,统一手写析构调用。


这是极少数需要手动调用析构函数的场景。不要对普通对象手动调析构,否则双重析构会导致未定义行为。

5. 绝对不能手动调用析构的场景

  1. 普通栈对象
A a;
a.~A(); // 错误!出作用域自动再析构一次 → 双重析构崩溃
  1. new出来的堆对象
A* p = new A;
p->~A(); delete p; // 双重析构
// 正确:直接 delete p; 自动先析构再释放内存

9. 常见陷阱

9.1 在析构函数中调用虚函数

class Base {
public:
    virtual ~Base() { log(); }  // 析构函数中调用虚函数
    virtual void log() { std::cout << "Base log\n"; }
};

class Derived : public Base {
public:
    void log() override { std::cout << "Derived log\n"; }
    ~Derived() {}
};

Derived d;  // 销毁时输出 "Base log",不是 "Derived log"

在析构函数中调用虚函数,调用的是当前类的版本,不是派生类的。因为此时派生类部分已经被析构了,对象退化到当前类。这个行为虽然不危险(不会访问已销毁的派生类成员),但可能不符合预期。

9.2 在容器中使用裸指针

{
    std::vector<Base*> vec;
    vec.push_back(new Derived());
    vec.push_back(new Derived());
}  
// 内存泄漏!vector 只释放了指针本身,没有 delete 指向的对象

解决:用 std::vector<std::unique_ptr<Base>>std::vector<std::shared_ptr<Base>>

9.3 不完整的类型声明

// forward_decl.h
class ForwardDeclared;

class Holder {
    ForwardDeclared* ptr;
public:
    Holder();
    ~Holder() { delete ptr; }  // 危险!ForwardDeclared 是不完整类型
};

如果 ForwardDeclared 的析构函数有什么特殊逻辑,这里可能出错。最好把析构函数的定义放在 .cpp 文件中,确保此时类型是完整的。

10. 面试常考清单

10.1 析构函数必须是虚的吗?什么时候是?

答案要点:作为基类的类,析构函数必须是虚的。如果类不会被继承,不需要虚析构(或者标记为 final)。虚析构保证通过基类指针删除派生类对象时,派生类的析构函数被正确调用。

10.2 析构函数可以重载吗?可以有参数吗?

答案要点:不能重载,不能有参数。一个类只有一个析构函数,因为销毁对象只需要一种方式。

10.3 析构函数可以抛出异常吗?

答案要点:绝对不要。C++11 起析构函数隐式是 noexcept。如果析构函数抛异常,程序会 std::terminate。析构函数内部必须自行捕获并处理所有异常。

10.4 什么场景需要手动调用析构函数?

答案要点placement new 在自定义内存上构造对象后,需要手动调用析构函数(但不释放内存)。普通对象绝不需要手动调用析构。

10.5 析构顺序是什么?

答案要点:派生类析构函数体 → 派生类成员(逆声明顺序)→ 基类析构函数体 → 基类成员。与构造顺序严格相反。

10.6 RAII 是什么?它在 C++ 中有多重要?

答案要点:Resource Acquisition Is Initialization,在构造函数中获取资源,在析构函数中释放资源。它是 C++ 资源管理的基础,提供了异常安全保证,标准库中的智能指针、锁管理、容器都基于 RAII。

10.7 三/五法则和析构函数的关系?

答案要点:如果你需要自定义析构函数(说明管理了某种资源),那么大概率也需要自定义拷贝构造、拷贝赋值、移动构造、移动赋值。反之,如果所有成员都正确使用 RAII 管理资源(零法则),则不需要自定义析构。

10.8 什么时候用虚析构,什么时候用纯虚析构?

答案要点

  • 虚析构:基类有具体实现,可实例化
  • 纯虚析构:想让类成为抽象类(不能实例化),但又没有其他合适的纯虚函数。必须在类外提供定义。

10.9 delete 和 delete[] 在析构时的区别?

答案要点

  • delete 调用一次析构函数(用于单个对象)
  • delete[] 调用数组中每个元素的析构函数(元素数量由编译器在分配时记录的元数据获得)
  • 混用是未定义行为

11. 实践准则

  1. 基类析构必须虚:但凡可能被继承,就加 virtual ~类名() = default;
  2. 析构绝不抛异常:C++11 起默认就是 noexcept
  3. 遵循三/五法则或零法则:写了析构就检查拷贝和移动是否需要自定义
  4. 拥抱 RAII:用智能指针、标准容器管理资源,让编译器帮你写析构
  5. 成员用 RAII 类型std::string 代替 char*std::vector 代替动态数组,std::unique_ptr 代替裸指针

析构函数看似简单,但它承载着 C++ 资源管理哲学的核心:自动化、安全、确定性的资源释放。理解析构函数,就是理解 C++ 如何做到"不丢资源、不崩程序"。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐