4.5. 常见陷阱

析构函数虽好,但需谨慎使用——尤其是异常处理问题。本节标题已直指要害:析构函数不应抛出异常(尽管语法允许,但危害极大)。

4.5.1 析构函数禁止抛出异常

这与构造函数形成鲜明对比:构造函数抛出异常是合理行为,表明对象构造失败(未满足后置条件),此时尚未完成构造的对象会自动清理。

但析构函数抛出异常则可能导致程序直接终止。因为:

#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()),这使得编译器可以对代码进行激进优化。

总结:除非非常清楚自己在做什么,能完全控制调用上下文,并经过团队充分讨论认可(尽管所有证据都表明不该这么做),否则永远不要在析构函数中抛出异常。即便如此,寻找替代方案通常是更明智的选择。

4.5.2 掌握对象析构顺序

本节标题看似有些滑稽——为什么要关心对象的析构顺序?基础规则不是很明确吗:对象的构造和析构对称,因此析构顺序与构造顺序相反……对吗?这个规则确实适用于局部自动对象。

例如以下代码:

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)
  1. 最先构造(最后析构)的是静态内联成员v0(也是首个全局对象)
  2. 接着是第二个全局对象v2
  3. 进入main()后构造v6(将在main结束时析构)
  4. 关键转折点出现在构造v7(内层作用域对象,很快被析构)后首次调用f()
  5. f()中的静态对象v3虽在v7之后构造,但作为全局对象将晚于v6和v7析构

整个过程虽然是确定性的,但需要仔细分析。若依赖析构函数释放资源,错误理解时序可能导致访问已释放资源。举个涉及自动/手动资源管理的具体案例:动态链接库(.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);
}

这个特定案例能找到简单解决方案,其他情况下可能需要调整声明位置,确保对象所在作用域不会违背函数的设计语义。理解对象析构顺序,对于正确运用析构函数这一资源管理机制至关重要。