11.4. 作用域守卫与异常处理

ScopeGuard 模式旨在当退出某个作用域时,无论退出原因是什么 —— 正常执行到作用域末尾、提前返回,或是抛出异常 —— 都能自动正确地执行各种清理和回滚操作。这使得编写异常安全的代码变得容易得多;只要每次操作后正确地添加了相应的保护措施,正确的清理和错误处理就会自动发生。当然,这前提是假设 ScopeGuard 本身在存在异常的情况下也能正常工作。我们将介绍如何确保其正常运行,并利用它使其余代码具备异常安全性。

11.4.1 绝不应抛出异常的内容

因为,用于提交操作并解除回滚保护的 commit() 函数绝不能抛出异常,这很容易保证,该函数所做的仅仅是设置一个标志。但如果回滚函数本身也执行失败并抛出异常,会发生什么呢?

// Example 09
void Database::insert(const Record& r) {
  S.insert(r);
  auto SF = MakeGuard([&] { S.finalize(); });
  auto SG = MakeGuard([&] { S.undo(); });
            // What if undo() can throw?
  I.insert(r); // Let's say this fails
  SG.commit(); // Commit never happens
} // Control jumps here and undo() throws

回答是:结果不会好,会陷入两难境地 —— 不允许操作(在本例中是存储插入)保留,但又无法撤销它,撤销操作本身也失败了。具体来说,在 C++ 中,不能同时传播两个异常。正因如此,析构函数不允许抛出异常;当抛出异常时,可能会调用析构函数,如果该析构函数也抛出异常,那么就会有两个异常同时传播,程序将立即终止。这与其说是语言的缺陷,不如说是反映了这个问题在本质上无法解决:我们不能让事情保持原状,但在试图改变时又失败了,此时已没有好的解决方案。

通常,C++ 程序有三种方式来处理这种情况。最佳选择是避免陷入这种陷阱 —— 如果回滚操作不会抛出异常,这一切都不会发生。编写良好的异常安全程序,会尽一切努力提供不会抛出异常的回滚和清理操作。例如,主操作可以先生成新数据并做好准备,然后通过交换指针的方式将数据提供给调用者,这显然是一个不会抛出异常的操作。回滚操作仅涉及将指针交换回去,可能还需要删除某些内容(正如之前所言,析构函数不应抛出异常;如果抛出,程序行为将未定义)。

第二种选择是在回滚过程中抑制异常。我们尝试撤销操作,但没有成功,对此已无能为力,只能继续执行。这里的危险在于,程序可能处于未定义状态,从开始的每一个操作都可能是错误的,这是最坏的情况。在实践中,后果可能不那么严重。对于我们的数据库,如果回滚失败,会有部分磁盘空间记录占用,但从索引中无法访问插入失败,还会浪费了一些磁盘空间,但这可能比直接终止程序更可取。如果这是我们想要的结果,必须捕获 ScopeGuard 操作可能抛出的异常:

// Example 09a
template <typename Func>
class ScopeGuard : public ScopeGuardBase {
public:
  ...
  ~ScopeGuard() {
    if (!commit_) try { func_(); } catch (...) {}
  }
  ...
};

catch 子句为空;我们捕获所有异常但不进行处理。这种实现有时称为“受保护的 ScopeGuard”。

最后一种选择是允许程序失败。如果放任两个异常同时发生,程序自然会失败,但也可以打印一条消息,或以其他方式向用户说明即将发生的情况及其原因。如果希望在程序终止前执行自己的终止操作,则必须编写与之前非常相似的代码:

template <typename Func>
class ScopeGuard : public ScopeGuardBase {
public:
  ...
  ~ScopeGuard() {
    if (!commit_) try { func_(); } catch (...) {
      std::cout << "Rollback failed" << std::endl;
      throw; // Rethrow
    }
  }
  ...
};

关键区别在于没有参数的 throw; 语句,会重新抛出我们捕获的异常,使其继续向外传播。

最后这两个代码片段之间的差异突显了一个之前被我们忽略的细微之处,但在后续将变得非常重要。笼统地说“C++ 中析构函数不应抛出异常”是不准确的。准确的说法是:异常不应从析构函数中向外传播。只要析构函数能自己捕获所抛出的异常,就可以随意抛出:

class LivingDangerously {
public:
  ~LivingDangerously() {
    try {
    if (cleanup() != SUCCESS) throw 0;
      more_cleanup();
    } catch (...) {
      std::cout << "Cleanup failed, proceeding anyway" <<
      std::endl;
      // No rethrow - this is critical!
    }
  }
};

我们一直主要将异常视为一种麻烦:程序必须在地方抛出异常时仍保持在明确定义的状态,除此之外,我们对这些异常并无其他用途,只是将其继续传递。另一方面,代码可以与类型的错误处理方式协同工作,无论是异常还是错误码。如果能确定错误总是通过异常来报告,并且函数的非异常返回都表示成功,就可以利用来自动检测操作的成功或失败,从而在需要时自动执行提交或回滚操作。

11.4.2 异常驱动的ScopeGuard

假设,一个函数返回时没有抛出异常,则表示操作成功;如果函数抛出了异常,则显然表示操作失败。当前的目标是消除对 commit() 的显式调用,转而检测 ScopeGuard 的析构函数是因为抛出异常而执行,还是因为函数正常返回而执行。

该实现包含两个部分。第一部分是明确我们希望在何时执行操作。在无论以何种方式退出作用域时,清理保护都必须执行;回滚保护仅在发生失败时执行。为了完整性,还可以设置一种保护,仅在函数成功时才执行。第二部分是判断实际发生的情况。

我们先从第二部分开始。现在的 ScopeGuard 需要增加两个额外的参数,用于指示该保护是否在成功时执行,以及是否在失败时执行(两者可以同时启用)。只需要修改 ScopeGuard 的析构函数即可:

template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
public:
  ...
  ~ScopeGuard() {
    if ((on_success && is_success()) ||
        (on_failure && is_failure())) func_();
  }
  ...
};

我们仍然需要弄清楚如何实现 is_success() 和 is_failure() 这两个伪函数。请记住,失败意味着抛出了异常。在 C++ 中,有一个函数:std::uncaught_exception()。当有异常正在传播时,返回 true,否则返回 false。掌握了这点,就可以实现我们的保护机制了:

// Example 10
template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
public:
  ...
  ~ScopeGuard() {
  if ((on_success && !std::uncaught_exception()) ||
      (on_failure && std::uncaught_exception())) func_();
  }
  ...
};

现在回到第一部分:如果条件满足,ScopeGuard 将执行延迟的操作,该如何告诉它哪些条件是正确的呢?利用之前开发的宏方法,可以定义三种版本的保护:ON_SCOPE_EXIT 总会执行,ON_SCOPE_SUCCESS 仅在没有异常抛出时执行,而 ON_SCOPE_FAILURE 则在抛出异常时执行。因为不再需要显式调用 commit(),后者取代了我们之前的 ON_SCOPE_EXIT_ROLLBACK 宏,而且现在它也可以使用匿名变量名。这三个宏的定义方式非常相似,只需要三个不同的唯一类型来替代单一的 ScopeGuardOnExit 类型,以便决定调用哪个 operator+():

// Example 10
struct ScopeGuardOnExit {};
template <typename Func>
auto operator+(ScopeGuardOnExit, Func&& func) {
  return
    ScopeGuard<Func, true, true>(std::forward<Func>(func));
}

#define ON_SCOPE_EXIT auto ANON_VAR(SCOPE_EXIT_STATE) = \
  ScopeGuardOnExit() + [&]()

struct ScopeGuardOnSuccess {};
template <typename Func>
auto operator+(ScopeGuardOnSuccess, Func&& func) {
  return
    ScopeGuard<Func, true, false>(std::forward<Func>(func));
}
#define ON_SCOPE_SUCCESS auto ANON_VAR(SCOPE_EXIT_STATE) =\
  ScopeGuardOnSuccess() + [&]()

struct ScopeGuardOnFailure {};

template <typename Func>
auto operator+(ScopeGuardOnFailure, Func&& func) {
  return
    ScopeGuard<Func, false, true>(std::forward<Func>(func));
}

#define ON_SCOPE_FAILURE auto ANON_VAR(SCOPE_EXIT_STATE) =\
  ScopeGuardOnFailure() + [&]()

每个 operator+() 的重载都会使用不同的布尔参数构造一个 ScopeGuard 对象,以控制其何时执行或不执行。每个宏通过指定 operator+() 第一个参数的类型来引导 Lambda 表达式调用相应的重载,该类型是我们为此目的专门定义的三个唯一类型之一:ScopeGuardOnExit、ScopeGuardOnSuccess 和 ScopeGuardOnFailure。

这种实现可以通过简单甚至相当复杂的测试,看起来能够正常工作。但它存在一个致命缺陷 —— 无法正确检测成功或失败。当然,如果 Database::insert() 函数是从正常的控制流中调用的(无论其成功与否),则可以正常工作。但问题在于,可能会从其他对象的析构函数中调用 Database::insert(),而该对象可能用在一个抛出异常的作用域中:

class ComplexOperation {
  Database db_;
public:
  ...
  ~ComplexOperation() {
    try {
      db_.insert(some_record);
    } catch (...) {} // Shield any exceptions from insert()
  }
};

{
  ComplexOperation OP;
  throw 1;
} // OP.~ComplexOperation() 在这里执行

现在,db_.insert() 是在存在未捕获异常的情况下运行的,因此 std::uncaught_exception() 将返回 true。但问题在于,这个异常并不是我们关心的那个。该异常并不表示 insert() 调用失败了,从而导致数据库插入操作错误地回滚。

我们真正需要的是知道当前正在传播的异常数量。这听起来可能有些奇怪,C++ 不允许同时传播多个异常。然而,这种说法过于简化了:只要第二个异常不从析构函数中逃逸,就可以正常传播。同理,如果有嵌套的析构函数调用,三个或更多的异常也可以同时传播,只需及时捕获即可。为了正确解决这个问题,首先需要知道在调用 Database::insert() 函数时,有多少个异常正在传播。然后,可以将这个数值与函数结束时(无论以何种方式到达)正在传播的异常数量进行比较。如果这两个数值相同,说明 insert() 没有抛出异常,而先前存在的异常则与我们无关。如果异常数量增加了,说明 insert() 失败了,退出时的处理逻辑必须相应地改变。

C++17 使能够实现这种检测:除了已弃用(并在 C++20 中移除)的 std::uncaught_exception() 之外,现在有了一个新的函数 std::uncaught_exceptions(),可以返回当前正在传播的异常数量。现在,可以实现一个 UncaughtExceptionDetector 来检测是否出现了新的异常:

// Example 10a
class UncaughtExceptionDetector {
public:
  UncaughtExceptionDetector() :
    count_(std::uncaught_exceptions()) {}
  operator bool() const noexcept {
    return std::uncaught_exceptions() > count_;
  }
private:
  const int count_;
};

有了这个检测器,终于可以实现自动化的 ScopeGuard 了:

// Example 10a
template <typename Func, bool on_success, bool on_failure>
class ScopeGuard {
public:
  ...
  ~ScopeGuard() {
  if ((on_success && !detector_) ||
      (on_failure && detector_)) func_();
  }
  ...
private:
  UncaughtExceptionDetector detector_;
  ...
};

使用 C++17 可能会给受限于旧版本语言标准的程序带来(希望是短期的)障碍。虽然没有其他标准合规且可移植的方法来解决此问题,但大多数现代编译器都提供了访问未捕获异常计数器的方式。在 GCC 或 Clang 中是这样实现的(以 __ 开头的名称是 GCC 内部的类型和函数):

// Example 10b
namespace cxxabiv1 {
  struct cxa_eh_globals;
  extern "C" cxa_eh_globals* cxa_get_globals() noexcept;
}

class UncaughtExceptionDetector {
public:
  UncaughtExceptionDetector() :
    count_(uncaught_exceptions()) {}

  operator bool() const noexcept {
    return uncaught_exceptions() > count_;
  }
private:
  const int count_;
  int uncaught_exceptions() const noexcept {
    return *(reinterpret_cast<int*>(
      static_cast<char*>( static_cast<void*>(
        cxxabiv1::cxa_get_globals())) + sizeof(void*)));
  }
};

无论使用由异常驱动的 ScopeGuard,还是使用显式命名的 ScopeGuard(也许是为了同时处理错误码和异常),都已实现了目标 —— 现在能够指定在函数或其他作用域结束时,必须执行的延迟操作。

本章的最后,将展示另一种可以在网上多个来源找到的 ScopeGuard 实现。这种实现值得关注,但也应该意识到其所存在的一些缺点。