C++ 析构函数完全指南:对象销毁背后的秘密
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) 实现:
- 基类有虚析构函数 → 类有 vtable
- 对象有 vptr 指向 vtable
delete基类指针时,通过 vptr 找到正确的析构函数(派生类的析构函数)- 派生类析构执行完后,自动调用基类析构
// 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::vector、std::string、std::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. 绝对不能手动调用析构的场景
- 普通栈对象
A a;
a.~A(); // 错误!出作用域自动再析构一次 → 双重析构崩溃
- 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. 实践准则
- 基类析构必须虚:但凡可能被继承,就加
virtual ~类名() = default; - 析构绝不抛异常:C++11 起默认就是
noexcept - 遵循三/五法则或零法则:写了析构就检查拷贝和移动是否需要自定义
- 拥抱 RAII:用智能指针、标准容器管理资源,让编译器帮你写析构
- 成员用 RAII 类型:
std::string代替char*,std::vector代替动态数组,std::unique_ptr代替裸指针
析构函数看似简单,但它承载着 C++ 资源管理哲学的核心:自动化、安全、确定性的资源释放。理解析构函数,就是理解 C++ 如何做到"不丢资源、不崩程序"。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)