析构函数虽好,但需谨慎使用——尤其是异常处理问题。本节标题已直指要害:析构函数不应抛出异常(尽管语法允许,但危害极大)。
这与构造函数形成鲜明对比:构造函数抛出异常是合理行为,表明对象构造失败(未满足后置条件),此时尚未完成构造的对象会自动清理。
但析构函数抛出异常则可能导致程序直接终止。因为:
默认情况下析构函数隐式标记为noexcept
抛出异常会触发std::terminate()
即使显式声明noexcept(false)规避默认行为
若在栈展开过程中抛出(已有异常在传播时)
仍会触发std::terminate()
更糟的是可能引发未定义行为(示例中"A\n"和"B\n"可能都不会输出)
#include <iostream>
class Darn {};
void f() { throw 3; }
struct Evil {
Evil() { std::cout << "Evil::Evil()\n"; }
~Evil() noexcept(false) {
std::cout << "Evil::~Evil()\n";
throw Darn {};
}
};
void g() {
std::cout << "A\n";
Evil e;
std::cout << "B\n";
f();
std::cout << "C\n";
}
int main() {
try {
g();
} catch(int) {
std::cerr << "catch(int)\n";
} catch(Darn) {
std::cerr << "darn...\n";
}
}
这段代码很可能什么输出都没有,或只会显示"throwing Darn导致调用了std::terminate()"之类的信息。为什么编译器会直接优化掉输出的信息?因为抛出但未捕获的异常会导致未定义行为,而此处的Darn异常无法捕获(栈展开过程中直接触发了std::terminate()),这使得编译器可以对代码进行激进优化。
总结:除非非常清楚自己在做什么,能完全控制调用上下文,并经过团队充分讨论认可(尽管所有证据都表明不该这么做),否则永远不要在析构函数中抛出异常。即便如此,寻找替代方案通常是更明智的选择。
本节标题看似有些滑稽——为什么要关心对象的析构顺序?基础规则不是很明确吗:对象的构造和析构对称,因此析构顺序与构造顺序相反……对吗?这个规则确实适用于局部自动对象。
例如以下代码:
void f() {
A a; // 调用a的构造函数
B b; // 调用b的构造函数
{
C c; // 调用c的构造函数
} // 调用c的析构函数
D d; // 调用d的析构函数
} // 先调用d的构造函数, 再调用b的构造函数, 再调用a的构造函数 (按这个顺序)
对象的构造和析构顺序正如注释所示:作用域内的自动对象按构造的逆序析构,嵌套作用域的表现也符合预期。
但当引入非自动对象时,情况就变得复杂了。C++允许在函数内声明静态对象 —— 这些对象在函数首次调用时构造,并持续存活至程序结束;也允许全局变量(涉及static或extern等链接属性的诸多细节);还允许类的静态数据成员(本质上也是全局变量)。暂不讨论thread_local变量(这超出了本书范围),但需注意其延迟初始化特性会进一步增加复杂性。
全局对象虽然按构造的逆序析构,但其构造顺序往往难以直观预测。例如下面这个使用Verbose对象的示例(该对象会输出其构造和析构时刻)
#include <iostream>
#include <format>
struct Verbose {
int n;
Verbose(int n) : n{ n } {
std::cout << std::format("Verbose({})\n", n);
}
~Verbose(){
std::cout << std::format("~Verbose({})\n", n);
}
};
class X {
static inline Verbose v0 { 0 };
Verbose v1{ 1 };
};
Verbose v2{ 2 };
static void f() {
static Verbose v3 { 3 };
Verbose v4{ 4 };
}
static void g() { // 注意:从未调用过
static Verbose v5 { 5 };
}
int main() {
Verbose v6{ 6 };
{
Verbose v7{ 7 };
f();
X x;
}
f();
X x;
}
请仔细阅读这个示例,尝试推测输出结果。有一个全局对象v0、一个类的静态内联数据成员、两个函数内的静态局部对象,以及若干局部自动对象。
运行程序时,实际输出将显示:
Verbose(0)
Verbose(2)
Verbose(6)
Verbose(7)
Verbose(3)
Verbose(4)
~Verbose(4)
Verbose(1)
~Verbose(1)
~Verbose(7)
Verbose(4)
~Verbose(4)
Verbose(1)
~Verbose(1)
~Verbose(6)
~Verbose(3)
~Verbose(2)
~Verbose(0)
整个过程虽然是确定性的,但需要仔细分析。若依赖析构函数释放资源,错误理解时序可能导致访问已释放资源。举个涉及自动/手动资源管理的具体案例:动态链接库(.dll)。虽然C++标准未规范此行为(Linux用.so,Mac用.dylib),但其核心机制相同。
程序将执行以下步骤:(a)加载动态链接库,(b)获取函数地址,(c)调用该函数,(d)卸载库。假设库名为"Lib",要调用的函数是返回X*的"factory",我们需要通过该指针调用成员函数f():
#include "Lib.h"
#include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// 假设factory的签名是Lib.h
auto factory_ptr = reinterpret_cast<
decltype(&factory)
>(GetProcAddress(hMod, "factory"));
X *p = factory_ptr();
p->f();
delete p;
FreeLibrary(hMod);
}
各位读者可能已注意到其中的手动内存管理问题:通过factory_ptr调用factory()获取资源(X*指针)后,再使用该资源(调用f())并手动释放,但手动管理资源或许并非最佳选择(如果p→f()抛出异常怎么办?)。查阅标准库后,可以使用std::unique_ptr来自动管理资源。
这看似完美,但请考虑以下关键问题:当使用智能指针自动管理资源时,库的卸载时机是否正确?智能指针的析构发生在何时?这引出了更深层的资源生命周期管理问题。
#include "Lib.h"
#include <memory> // std::unique_ptr
#include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// 假设factory的签名是Lib.h
auto factory_ptr = reinterpret_cast<
decltype(&factory)
>(GetProcAddress(hMod, "factory"));
std::unique_ptr<X> p { factory_ptr() };
p->f();
// delete p; // 不需要了
FreeLibrary(hMod);
} // p在这里析构了,但这样真的好吗?
乍看之下,这个新版本似乎更安全 —— 因为p现在是个RAII对象,负责管理指针目标的销毁。当程序执行到main()函数的右花括号时,即使p→f()抛出异常,指针目标的析构函数也必定会调用,所以可以认为代码的异常安全性得到了提升……
……然而,实际上程序会在右花括号处崩溃!若追溯崩溃根源,会发现崩溃发生在p的析构函数对其内部存储的X*执行operator delete时。进一步分析可知,崩溃原因是对象的来源库(通过FreeLibrary()卸载)在析构函数运行前就释放了。
这是否表明自动化内存管理工具在此场景不可用?非也,但需要更谨慎地规划对象生命周期。本例中,必须确保p在调用FreeLibrary()前完成析构,通过简单地引入作用域即可实现:
#include "Lib.h"
#include <memory> // std::unique_ptr
#include <Windows.h> // LoadLibrary, GetProcAddress
int main() {
using namespace std;
HMODULE hMod = LoadLibrary(L"Lib.dll");
// 假设factory的签名是Lib.h
auto factory_ptr = reinterpret_cast<
decltype(&factory)
>(GetProcAddress(hMod, "factory"));
{
std::unique_ptr<X> p { factory_ptr() };
p->f();
} // p 在此析构
FreeLibrary(hMod);
}
这个特定案例能找到简单解决方案,其他情况下可能需要调整声明位置,确保对象所在作用域不会违背函数的设计语义。理解对象析构顺序,对于正确运用析构函数这一资源管理机制至关重要。