在本节中,将介绍如何编写上一节中实现的这类“退出时执行”操作的 RAII 类,但无需编写大量样板代码。这一功能在 C++03 中可以实现,但在 C++14 和 C++17 中得到了显著改进。
先解决一个更复杂的问题 —— 如何实现一个通用的回滚类,也就是上一节中 StorageGuard 的通用版本。回滚类与清理类的唯一区别在于,清理操作始终会执行,而回滚操作在操作提交后会被取消。如果实现了条件性回滚版本,总可以通过移除条件检查来得到清理版本,因此暂时不必担心清理类的问题。
在示例中,回滚操作是调用 S.undo() 方法。为了简化示例,先从调用普通函数(而非成员函数)的回滚开始:
void undo(Storage& S) { S.undo(); }
当实现完成,程序应该看起来像这样:
{
S.insert(r);
ScopeGuard SG(undo, S); // 期望语法的近似写法
...
SG.commit(); // 停用该作用域守护
}
这段代码以声明式的方式说明:如果插入操作成功,安排在作用域退出时执行回滚操作。回滚将使用参数 S 调用 undo() 函数,从而撤销插入操作。如果成功执行到函数末尾,则解除守卫,禁用回滚调用,从而提交插入操作并使其永久生效。
Andrei Alexandrescu 在 2000 年发表于 Dr. Dobb's 的一篇文章中提出了一种更为通用和可复用的解决方案(http://www.drdobbs.com/cpp/generic-change-the-way-youwrite-excepti/184403758?)。来看一下其实现并进行分析:
// Example 04
class ScopeGuardImplBase {
public:
ScopeGuardImplBase() = default;
void commit() const noexcept { commit_ = true; }
protected:
ScopeGuardImplBase(const ScopeGuardImplBase& other) :
commit_(other.commit_) { other.commit(); }
~ScopeGuardImplBase() {}
mutable bool commit_ = false;
};
template <typename Func, typename Arg>
class ScopeGuardImpl : public ScopeGuardImplBase {
public:
ScopeGuardImpl(const Func& func, Arg& arg) :
func_(func), arg_(arg) {}
~ScopeGuardImpl() { if (!commit_) func_(arg_); }
private:
const Func& func_;
Arg& arg_;
};
template <typename Func, typename Arg>
ScopeGuardImpl<Func, Arg>
MakeGuard(const Func& func, Arg& arg) {
return ScopeGuardImpl<Func, Arg>(func, arg);
}
从顶层开始,有所有作用域守护的基类,即 ScopeGuardImplBase。该基类保存了提交标志以及操作该标志的代码;构造函数最初以“已启用”状态创建守护,因此延迟的操作会在析构函数中执行。调用 commit() 将阻止该操作发生,并使析构函数不执行任何操作。最后,有一个复制构造函数,会创建一个与原对象状态相同的新守护,但随后会禁用原守护。这是为了防止析构函数从两个对象中都执行回滚操作。该对象是可复制的,但不可赋值。这里使用了 C++03 的特性,包括禁用的赋值操作符。这个实现本质上是 C++03 的;其中少量的 C++11 特性只是锦上添花(这种情况将在下一节中改变)。
ScopeGuardImplBase 的实现中有几个细节可能看起来有些奇怪,需要进一步解释。首先,析构函数不是虚函数;这并非笔误或错误,而是有意为之,稍后会看到原因。其次,commit_ 标志声明为 mutable。这当然是为了让 commit() 方法能够修改,将该方法声明为 const。那么,为什么 commit() 声明为 const?原因之一是,可以从复制构造函数中调用,以便将回滚的责任从原对象转移到新对象。从这个意义上说,复制构造函数实际上执行了移动操作,并将在后面正式声明为移动操作。const 的第二个原因稍后会变得明显(这与非虚析构函数有关,尽管看起来不太相关)。
现在转向派生类 ScopeGuardImpl。这是一个带有两个类型参数的类模板 —— 第一个是用于回滚的函数或可调用对象的类型,第二个是参数的类型。目前,回滚函数限制为只有一个参数。该函数将在 ScopeGuard 对象的析构函数中调用,除非该守卫已使用 commit() 调用解除激活。
最后,有一个工厂函数模板 MakeGuard。这是 C++ 中一个非常常见的惯用法;如果需要根据构造函数参数创建一个类模板的实例,可以使用一个模板函数,该函数能够从参数中推导出参数类型和返回值类型(在 C++17 中,类模板也可以做到)。
那么,这一切如何用于创建一个将为调用 undo(S) 的守卫对象呢?就像这样:
void Database::insert(const Record& r) {
S.insert(r);
const ScopeGuardImplBase& SG = MakeGuard(undo, S);
I.insert(r);
SG.commit();
}
MakeGuard 函数推导出 undo() 函数和参数 S 的类型,并返回相应类型的 ScopeGuard 对象。返回是按值进行的,因此涉及一次复制(编译器可能会选择省略该复制作为优化,但不必须)。返回的对象是一个临时变量,它没有名字,并绑定到基类引用 SG 上(从派生类到基类的转换对于指针和引用都是隐式的)。临时变量的生命周期持续到,创建语句末尾的分号处。那么,在语句结束后,SG 引用指向了什么?必须绑定到某个对象上,因为引用不能解绑(不像空指针)。
事实上,“众所周知”的说法是错误的,或者更准确地说,只是大部分情况下正确 —— 通常,临时对象确实会存活到语句结束。但将临时对象绑定到 const 引用上会将其生命周期延长,使其与引用本身的生命周期一致。换句话说,由 MakeGuard 创建的无名临时 ScopeGuard 对象,直到 SG 引用离开作用域时才会销毁。这里的 const 性质很重要,但不用担心会忘记;语言不允许将非 const 引用绑定到临时变量上,因此编译器会进行提醒。这解释了 commit() 方法为什么必须是 const 的 —— 因为将在 const 引用上调用它(commit_ 标志必须为 mutable)。
析构函数呢?作用域结束时,将调用 ScopeGuardImplBase 类的析构函数,这是离开作用域的引用的类型。基类析构函数本身不执行操作,我们想要的是派生类的析构函数。一个带有虚析构函数的多态类本可以满足需求,但我们没有选择这条路。相反,我们利用了 C++ 标准中关于 const 引用和临时变量的另一条特殊规则 —— 不仅临时变量的生命周期延长,而且实际构造的派生类的析构函数也将在作用域结束时调用。这条规则仅适用于析构函数;但仍然不能在基类 SG 引用上调用派生类的方法。此外,生命周期延长仅在临时变量直接绑定到 const 引用时才有效。如果从第一个引用初始化另一个 const 引用,则不会生效。这就是为什么必须从 MakeGuard 函数按值返回 ScopeGuard 对象;如果尝试按引用返回,临时对象将绑定到该引用上,而该引用将在语句结束时消失。从第一个引用初始化的第二个引用 SG ,将不会延长对象的生命周期。
刚刚看到的函数实现已经非常接近最初的目标,只是稍微冗长了一些(并且提到了 ScopeGuardImplBase,而非承诺的 ScopeGuard)。不用担心,最后一步只是语法糖:
using ScopeGuard = const ScopeGuardImplBase&;
现在,可以这样写:
// Example 04a
void Database::insert(const Record& r) {
S.insert(r);
ScopeGuard SG = MakeGuard(undo, S);
I.insert(r);
SG.commit();
}
这已经是我们目前所使用的语言工具所能达到的极限了。理想情况下,期望的语法应该是这样的(而且已经非常接近了):
ScopeGuard SG(undo, S);
可以利用 C++11 的特性对 ScopeGuard 进行一些改进。首先,可以更恰当地禁用赋值操作符(例如使用 = delete)。其次,可以不再假装复制构造函数是别的东西,而应该明确地将其定义为移动构造函数:
// Example 05
class ScopeGuardImplBase {
public:
ScopeGuardImplBase() = default;
void commit() const noexcept { commit_ = true; }
protected:
ScopeGuardImplBase(ScopeGuardImplBase&& other) :
commit_(other.commit_) { other.commit(); }
~ScopeGuardImplBase() {}
mutable bool commit_ = false;
private:
ScopeGuardImplBase& operator=(const ScopeGuardImplBase&) = delete;
};
using ScopeGuard = const ScopeGuardImplBase&;
template <typename Func, typename Arg>
class ScopeGuardImpl : public ScopeGuardImplBase {
public:
ScopeGuardImpl(const Func& func, Arg& arg) :
func_(func), arg_(arg) {}
~ScopeGuardImpl() { if (!commit_) func_(arg_); }
ScopeGuardImpl(ScopeGuardImpl&& other) :
ScopeGuardImplBase(std::move(other)),
func_(other.func_),
arg_(other.arg_) {}
private:
const Func& func_;
Arg& arg_;
};
template <typename Func, typename Arg>
ScopeGuardImpl<Func, Arg>
MakeGuard(const Func& func, Arg& arg) {
return ScopeGuardImpl<Func, Arg>(func, arg);
}
进入 C++14 后,可以进一步简化,让 MakeGuard 函数的返回类型自动推导:
// Example 05a
template <typename Func, typename Arg>
auto MakeGuard(const Func& func, Arg& arg) {
return ScopeGuardImpl<Func, Arg>(func, arg);
}
这样,MakeGuard 函数可以自动推导出返回类型,进一步简化了代码的编写。
我们仍然做了一个妥协 —— 其实并不需要 undo(S) 这样的独立函数,我们真正想要的是调用 S.undo()。这可以通过 ScopeGuard 的成员函数版本轻松实现。事实上,从一开始没有这样做,只是为了使示例更容易理解;成员函数指针的语法并不是 C++ 中最直观的部分:
// Example 06
template <typename MemFunc, typename Obj>
class ScopeGuardImpl : public ScopeGuardImplBase {
public:
ScopeGuardImpl(const MemFunc& memfunc, Obj& obj) :
memfunc_(memfunc), obj_(obj) {}
~ScopeGuardImpl() { if (!commit_) (obj_.*memfunc_)(); }
ScopeGuardImpl(ScopeGuardImpl&& other) :
ScopeGuardImplBase(std::move(other)),
memfunc_(other.memfunc_),
obj_(other.obj_) {}
private:
const MemFunc& memfunc_; Obj& obj_;
};
template <typename MemFunc, typename Obj>
auto MakeGuard(const MemFunc& memfunc, Obj& obj) {// C++14
return ScopeGuardImpl<MemFunc, Obj>(memfunc, obj);
}
当然,如果在同一个程序中同时使用了 ScopeGuard 的两种版本,就必须重命名其中一个。此外,函数守卫目前仅限于调用带有一个参数的函数,而成员函数守卫只能调用无参数的成员函数。在 C++03 中,这个问题是通过一种繁琐但可靠的方式来解决的 —— 必须为不同参数数量的函数创建多个实现版本,例如 ScopeGuardImpl0、ScopeGuardImpl1、ScopeGuardImpl2 等,分别用于处理零个、一个、两个等参数的函数。同样,也需要为成员函数创建 ScopeObjGuardImpl0、ScopeObjGuardImpl1 等版本。如果没有创建足够的版本,编译器会提醒我们。所有这些派生类变体共享同一个基类,ScopeGuard 的类型别名也保持不变。
在 C++11 中,我们有了可变参数模板,它正是为解决这类问题而设计的,但这里不会展示这种实现。原因很简单:我们可以做得更好,正如即将看到的那样。
如果在同一个程序中同时使用了 ScopeGuard 的两种版本,就必须重命名其中一个。此外,我们的函数守卫目前仅限于调用带有一个参数的函数,而成员函数守卫只能调用无参数的成员函数。C++03 中,这个问题可通过一种繁琐但可靠的方式来解决 —— 必须为不同参数数量的函数创建多个实现版本,例如 ScopeGuardImpl0、ScopeGuardImpl1、ScopeGuardImpl2 等,分别用于处理零个、一个、两个等参数的函数。同样,也需要为成员函数创建 ScopeObjGuardImpl0、ScopeObjGuardImpl1 等版本。如果没有创建足够的版本,编译器会提醒我们。所有这些派生类变体共享同一个基类,ScopeGuard 的类型别名也保持不变。
C++11 中,有了可变参数模板,正是为解决这类问题而设计的,但我们可以做得更好。
ScopeGuardImpl(const Func& func, Arg& arg);
大多数情况下可行,除非参数是常量或临时变量;这样,代码将无法编译。
C++11 提供了另一种创建任意可调用对象的方式:Lambda 表达式。Lambda 实际上是类,但其行为类似于函数,可以用括号调用。可以接受参数,也可以从外围作用域捕获变量,这消除了向函数调用本身传递参数的需要。还可以编写任意代码并将其封装在 Lambda 表达式中。这听起来非常适合作用域守卫;可以简单地写一段代码,表示“在作用域结束时运行这段代码”。
来看看一个使用 Lambda 表达式的 ScopeGuard:
// Example 07
class ScopeGuardBase {
public:
ScopeGuardBase() = default;
void commit() noexcept { commit_ = true; }
protected:
ScopeGuardBase(ScopeGuardBase&& other) noexcept :
commit_(other.commit_) { other.commit(); }
~ScopeGuardBase() = default;
bool commit_ = false;
private:
ScopeGuardBase& operator=(const ScopeGuardBase&) = delete;
};
template <typename Func>
class ScopeGuard : public ScopeGuardBase {
public:
ScopeGuard(Func&& func) : func_(std::move(func)) {}
ScopeGuard(const Func& func) : func_(func) {}
~ScopeGuard() { if (!commit_) func_(); }
ScopeGuard(ScopeGuard&& other) = default;
private:
Func func_;
};
template <typename Func>
ScopeGuard<Func> MakeGuard(Func&& func) {
return ScopeGuard<Func>(std::forward<Func>(func));
}
基类与之前基本相同,只是不再使用 const 引用技巧,因此 Impl 后缀也移除了;现在看到的不再是实现辅助工具,而是守卫类本身的基类;包含了处理 commit_ 标志的可复用代码。由于不再使用 const 引用,所以可以不再假装 commit() 方法为 const,并且可以去掉 commit_ 上的 mutable 声明。
另一方面,派生类则大不相同。首先,现在只有一个类用于所有类型的回滚,参数类型模板参数也会移除;取而代之的是一个函数对象,它将是一个 Lambda 表达式,并且会包含它所需的所有参数。析构函数与之前相同(除了可调用对象 func_ 不再有参数),移动构造函数也是如此。
但该对象的主要构造函数则大不相同;可调用对象按值存储,并从 const 引用或右值引用初始化,编译器会自动选择合适的重载。
MakeGuard 函数基本保持不变,也不再需要两个版本;可以使用完美转发(std::forward)将任意类型的参数转发到 ScopeGuard 的其中一个构造函数中。
以下是这个 ScopeGuard 的使用方式:
void Database::insert(const Record& r) {
S.insert(r);
auto SG = MakeGuard([&] { S.undo(); });
I.insert(r);
SG.commit();
}
用作 MakeGuard 参数的、充满标点符号的结构就是 Lambda 表达式,创建一个可调用对象,调用该对象将执行 Lambda 主体中的代码,例子中就是 S.undo()。Lambda 对象本身并未声明 S 变量,必须从外围作用域中捕获它。所有的捕获都是通过引用进行的([&])。最后,该对象调用时无需参数;括号可以省略,尽管 MakeGuard([&]() { S.undo(); }); 也是有效的。该函数不返回值,即返回类型为 void;这不需要显式声明。
目前为止,我们使用了 C++11 的 Lambda 表达式,但尚未利用更强大的 C++14 Lambda 特性。对于 ScopeGuard 来说,这种情况会持续。而在实践中,可能会仅仅为了自动推导返回类型而使用 C++14。
我们有意搁置了常规清理的问题,专注于错误处理和回滚操作。现在已经有了一个相当好用的 ScopeGuard,可以很容易地解决这些遗留问题:
// Example 07a
void Database::insert(const Record& r) {
S.insert(r);
auto SF = MakeGuard([&] { S.finalize(); });
auto SG = MakeGuard([&] { S.undo(); });
I.insert(r);
SG.commit();
}
为了支持清理操作,框架无需添加特殊内容。只需创建另一个从不解除的 ScopeGuard 即可。
还应该指出,在 C++17 中因为编译器可以从构造函数中推导出模板参数,所以不再需要 MakeGuard 函数:
// Example 07b
void Database::insert(const Record& r) {
S.insert(r);
ScopeGuard SF = [&] { S.finalize(); }; // C++17
ScopeGuard SG = [&] { S.undo(); };
I.insert(r);
SG.commit();
}
既然正在讨论如何让 ScopeGuard 的使用方式更加美观,那么应该考虑一些有用的宏。可以轻松地为始终执行的清理守卫编写一个宏,希望最终的语法看起来像这样:
ON_SCOPE_EXIT { S.finalize(); };
事实上,可以实现这种语法。首先,需要为守卫生成唯一名称,这个名称以前称为 SF,需要确保其唯一性。从现代 C++ 的前沿技术,回溯几十年,回到经典的 C 语言及其预处理器技巧,来为匿名变量生成唯一名称:
#define CONCAT2(x, y) x##y
#define CONCAT(x, y) CONCAT2(x, y)
#ifdef __COUNTER__
#define ANON_VAR(x) CONCAT(x, __COUNTER__)
#else
#define ANON_VAR(x) CONCAT(x, __LINE__)
#endif
__CONCAT__ 宏是预处理器中连接两个标记的方式(是的,需要两个宏,这就是预处理器的工作方式)。第一个标记是用户指定的前缀,第二个标记是某个唯一的东西。许多编译器支持一个预处理器变量 ___COUNTER__,每次使用时都会递增,永远不会重复,但它并不在标准中。如果 ___COUNTER__ 不可用,就必须使用行号 ___LINE__ 作为唯一标识符。当然,这只有在不把两个守卫放在同一行的情况下才是唯一的,所以请不要这样做。
现在有了生成匿名变量名的方法,就可以实现 _ON_SCOPE_EXIT 宏了。如果将代码作为宏参数传递来实现,应该没什么开销,但这不是我们想要的语法;参数必须放在括号内,所以最多只能得到 _ON_SCOPE_EXIT(S.finalize();)。此外,代码中的逗号会混淆预处理器,会将它们解释为宏参数之间的分隔符。如果仔细观察要求的语法 _ON_SCOPE_EXIT { S.finalize(); };,会发现这个宏根本没有参数,而 Lambda 表达式的主体只是在无参数宏之后键入的。因此,宏展开的结果必须是一个可以跟上左花括号的东西。实现方法如下:
// Example 08
struct ScopeGuardOnExit {};
template <typename Func>
ScopeGuard<Func> operator+(ScopeGuardOnExit, Func&& func) {
return ScopeGuard<Func>(std::forward<Func>(func));
}
#define ON_SCOPE_EXIT auto ANON_VAR(SCOPE_EXIT_STATE) = \
ScopeGuardOnExit() + [&]()
宏展开后,会声明一个以 SCOPE_EXIT_STATE 开头、后跟唯一数字的匿名变量,并以不完整的 Lambda 表达式 ([&]() 结尾,该表达式由花括号中的代码完成。为了不出现之前 MakeGuard 函数的右括号(宏无法生成这个右括号,因为宏在 Lambda 主体之前展开,所以无法在其后生成代码),必须用一个操作符来替换 MakeGuard 函数(或 C++17 中的 ScopeGuard 构造函数)。操作符的选择无关紧要;可以使用加法,但也可以使用二元操作符。操作符的第一个参数是一个独特类型的临时对象,将重载解析限制在之前定义的 operator+() 上(该对象本身并未使用,只需要其类型)。operator+() 本身正是 MakeGuard 以前的功能,它推导出 Lambda 表达式的类型并创建相应的 ScopeGuard 对象。这种技术唯一的缺点是 ON_SCOPE_EXIT 语句末尾的分号是必需的,如果忘记了,编译器会以最晦涩、最难以理解的方式进行提醒。
代码可以进一步进行整理:
// Example 08
void Database::insert(const Record& r) {
S.insert(r);
ON_SCOPE_EXIT { S.finalize(); };
auto SG = ScopeGuard([&] { S.undo(); });
I.insert(r);
SG.commit();
}
将相同的技术可应用于第二个守卫(即需要手动“提交”或解除的守卫),但这没那么简单;我们需要知道这个变量的名称,以便在其上调用 commit() 方法。可以定义一个类似的宏,不使用匿名变量,而是接受用户指定的名称:
// Example 08a
#define ON_SCOPE_EXIT_ROLLBACK(NAME) \
auto NAME = ScopeGuardOnExit() + [&]()
可以使用它来完成代码转换:
// Example 08a
void Database::insert(const Record& r) {
S.insert(r);
ON_SCOPE_EXIT { S.finalize(); };
ON_SCOPE_EXIT_ROLLBACK(SG){ S.undo(); };
I.insert(r);
SG.commit();
}
此时,应该重新审视可组合性的问题。对于三个各自具有回滚和清理操作的动作,现在有如下结构:
action1();
ON_SCOPE_EXIT { cleanup1; };
ON_SCOPE_EXIT_ROLLBACK(g2){ rollback1(); };
action2();
ON_SCOPE_EXIT { cleanup2; };
ON_SCOPE_EXIT_ROLLBACK(g4){ rollback2(); };
action3();
g2.commit();
g4.commit();
可以看到,这种模式如何轻易地扩展到任意数量的操作。不过,细心的读者可能会怀疑代码中存在一个漏洞 —— 回滚保护是否应该按照与构造顺序相反的顺序解除呢?这其实并非漏洞,所有 commit() 调用的顺序也不是相反的。原因在于 commit() 不可能抛出异常,其声明为 noexcept,而且实际实现也不会抛出异常。这对于 ScopeGuard 模式能够正常工作至关重要;如果 commit() 可能抛出异常,就无法保证所有回滚保护都能正确解除。在作用域结束时,一些操作都会回滚,而另一些则不会,从而使系统处于不一致的状态。
尽管 ScopeGuard 最初主要是为了简化异常安全代码的编写而设计的,但它与异常之间的交互关系远非简单,我们有必要对此进行更深入的探讨。