析构函数是什么
上一页你看到了构造函数:在对象诞生时运行、用来设置初始状态的特殊函数。析构函数正好是它的镜像:在对象消亡时运行、负责善后清理的特殊函数。
声明析构函数时,在类名前加一个波浪号(~)。它不接受参数,不返回任何值,一个类有且只有一个析构函数。你几乎从不手动调用它,C++ 会在合适的时机替你调用。
注意,析构函数的消息是在 main 执行完函数体之后、程序退出之前打印的。当 log 在右花括号处离开作用域时,C++ 替你运行了 ~Logger()。
析构函数何时运行
具体时机取决于对象存放在哪里:
- 栈(局部)对象在离开作用域时销毁,也就是到代码块的右花括号
}时。 - 堆对象(用
new创建)在你调用delete时销毁。如果你忘了delete,析构函数永远不会运行,就会造成内存泄漏。
这个例子把两者的区别展示出来:
对象按照与构造相反的顺序销毁。a 最先被构造,所以最后销毁。当对象之间相互依赖时,这种 LIFO(后进先出)的顺序就很重要。
析构函数为什么重要:RAII
析构函数真正的威力在于,它让清理工作变得自动,而且在出现异常时也安全。你不必在每条代码路径上都记得释放资源,而是把释放操作放进析构函数,由语言保证它一定会运行。这种模式叫作 RAII(Resource Acquisition Is Initialization,资源获取即初始化),它是现代 C++ 的支柱。
下面这个类拥有一块堆缓冲区:在构造函数中分配,在析构函数中释放,所以调用者自己永远不用碰 new/delete。
关键在于:即使在 squares 创建之后抛出了异常,栈也会展开,~IntArray() 照样会运行。正是这个保证让 RAII 如此可靠,也是为什么在好的 C++ 代码里你很少会写一个单独的 delete。
三法则(以及五法则)
带有自定义析构函数的类,几乎总是拥有某个裸资源,而这会带来一个隐藏的危险。编译器生成的拷贝构造函数和拷贝赋值运算符做的是浅拷贝:它们复制的是指针,而不是指针指向的缓冲区。于是两个对象持有同一个指针,两个析构函数都会 delete 它,导致重复释放(double free)而崩溃。
IntArray a(5);
IntArray b = a; // shallow copy: a.data and b.data are the SAME pointer
// at scope end: b's destructor frees the buffer,
// then a's destructor frees it AGAIN -> undefined behavior (double free)
由此得出三法则(Rule of Three):如果你写了析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,几乎肯定三个都需要。在 C++11 及之后,它扩展为五法则(Rule of Five),增加了移动构造函数和移动赋值运算符。
不过还有一条更好的法则:零法则(Rule of Zero)。设计类的时候,干脆不去管理裸资源。改为持有 std::vector、std::string 或智能指针,编译器生成的析构函数就会免费替你做正确的事。
默认就采用零法则。只有当你确实拥有一个没有任何标准类型替你包装的裸资源时,才去写自定义析构函数。
虚析构函数
当你通过基类指针删除一个对象时,析构函数必须是 virtual 的,否则只有基类部分会被销毁,派生类部分就泄漏了。这是多态代码中最常见的 bug 之一,而且默认情况下编译器不会警告你。
如果 ~Base 上没有 virtual,delete p 只会调用 ~Base(),这是未定义行为,对象的 Derived 部分永远得不到清理。经验法则:任何带有虚函数的类(多态基类)都需要虚析构函数。等你开始写派生类时,就会明白这一点为什么如此重要。
常见错误与坑
有几个陷阱几乎每个人都会踩到:
new/delete 不匹配。如果用 new[] 分配,就要用 delete[] 释放。把 new[] 和普通的 delete 混用(或者反过来)都是未定义行为。
忘记给基类析构函数加 virtual。如上所述,通过没有虚析构函数的基类指针删除派生类对象,会泄漏派生类部分。如果你写的类是准备被继承的,就把析构函数声明为 virtual。
让异常逃出析构函数。在栈展开过程中抛出异常的析构函数会终止你的程序。在现代 C++ 中,析构函数隐式地是 noexcept 的,所以要让清理代码不抛异常,或者在析构函数内部把异常吞掉。
写了不需要的析构函数。如果你的成员已经能自己完成清理,一个空的 ~ClassName() {} 只会增加干扰,还可能悄悄禁用移动操作。没有需要清理的东西时,就根本不要写析构函数。
下一步:继承
现在你已经看到了对象的完整生命周期:构造函数让它诞生,析构函数为它善后,而 virtual 析构函数保证当一个类建立在另一个类之上时,清理依然正确。最后这一点正是下一个重要概念的预告:继承,即一个类复用并扩展另一个类的数据和行为。下一页讲解如何从一个类派生出另一个类,构造和析构如何沿着类层次依次进行,以及你刚学到的这些内容如何组合在一起。
常见问题
C++ 中的析构函数是什么?
析构函数是一种名为 ~ClassName() 的特殊成员函数,在对象被销毁时自动运行,也就是对象离开作用域或者你 delete 它的时候。它的任务是清理:释放内存、关闭文件,或者释放对象拥有的任何资源。它不接受参数,没有返回类型,一个类只能有一个析构函数。
C++ 中的析构函数什么时候运行?
对于局部(栈上)对象,析构函数在它离开作用域时运行,也就是到右花括号 } 的时候。对于用 new 创建的堆对象,析构函数在你调用 delete 时运行。之后成员和基类会按照与构造相反的顺序自动销毁。
C++ 中一定要自己写析构函数吗?
不用。如果你的类只持有能自己完成清理的成员(比如 std::string、std::vector 或智能指针),编译器生成的析构函数就够了,不要自己写。只有当你的类拥有裸资源时,比如来自 new 的内存或一个打开的文件句柄,才需要自定义析构函数。