item14¶
条款十四:如果函数不抛出异常请使用 noexcept
¶
Item 14: Declare functions noexcept
if they won’t emit exceptions
在 C++98 中,异常说明(exception specifications)是喜怒无常的野兽。你不得不写出函数可能抛出的异常类型,如果函数实现有所改变,异常说明也可能需要修改。改变异常说明会影响客户端代码,因为调用者可能依赖原版本的异常说明。编译器不会在函数实现,异常说明和客户端代码之间提供一致性保障。大多数程序员最终都认为不值得为 C++98 的异常说明做得如此麻烦。
在 C++11 标准化过程中,大家一致认为异常说明真正有用的信息是一个函数是否会抛出异常。非黑即白,一个函数可能抛异常,或者不会。这种 " 可能 - 绝不 " 的二元论构成了 C++11 异常说的基础,从根本上改变了 C++98 的异常说明。(C++98 风格的异常说明也有效,但是已经标记为 deprecated(废弃))。在 C++11 中,无条件的 noexcept
保证函数不会抛出任何异常。
关于一个函数是否已经声明为 noexcept
是接口设计的事。函数的异常抛出行为是客户端代码最关心的。调用者可以查看函数是否声明为 noexcept
,这个可以影响到调用代码的异常安全性(exception safety)和效率。就其本身而言,函数是否为 noexcept
和成员函数是否 const
一样重要。当你知道这个函数不会抛异常而没加上 noexcept
,那这个接口说明就有点差劲了。
不过这里还有给不抛异常的函数加上 noexcept
的动机:它允许编译器生成更好的目标代码。要想知道为什么,了解 C++98 和 C++11 指明一个函数不抛异常的方式是很有用了。考虑一个函数 f
,它保证调用者永远不会收到一个异常。两种表达方式如下:
int f(int x) throw(); //C++98风格,没有来自f的异常
int f(int x) noexcept; //C++11风格,没有来自f的异常
如果在运行时,f
出现一个异常,那么就和 f
的异常说明冲突了。在 C++98 的异常说明中,调用栈(the call stack)会展开至 f
的调用者,在一些与这地方不相关的动作后,程序被终止。C++11 异常说明的运行时行为有些不同:调用栈只是 可能 在程序终止前展开。
展开调用栈和 可能 展开调用栈两者对于代码生成(code generation)有非常大的影响。在一个 noexcept
函数中,当异常可能传播到函数外时,优化器不需要保证运行时栈(the runtime stack)处于可展开状态;也不需要保证当异常离开 noexcept
函数时,noexcept
函数中的对象按照构造的反序析构。而标注“throw()
”异常声明的函数缺少这样的优化灵活性,没加异常声明的函数也一样。可以总结一下:
RetType function(params) noexcept; //极尽所能优化
RetType function(params) throw(); //较少优化
RetType function(params); //较少优化
这是一个充分的理由使得你当知道它不抛异常时加上 noexcept
。
还有一些函数更符合这个情况。移动操作是绝佳的例子。假如你有一份 C++98 代码,里面用到了 std::vector<Widget>
。Widget
通过 push_back
一次又一次的添加进 std::vector
:
std::vector<Widget> vw;
…
Widget w;
… //用w做点事
vw.push_back(w); //把w添加进vw
…
假设这个代码能正常工作,你也无意修改为 C++11 风格。但是你确实想要 C++11 移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保 Widget
有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(参见 Item17)。
当新元素添加到 std::vector
,std::vector
可能没地方放它,换句话说,std::vector
的大小(size)等于它的容量(capacity)。这时候,std::vector
会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。在 C++98 中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。这种方法使得 push_back
可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector
状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。
在 C++11 中,一个很自然的优化就是将上述复制操作替换为移动操作。但是很不幸运,这会破坏 push_back
的异常安全保证。如果 n 个元素已经从老内存移动到了新内存区,但异常在移动第 n+1 个元素时抛出,那么 push_back
操作就不能完成。但是原始的 std::vector
已经被修改:有 n 个元素已经移动走了。恢复 std::vector
至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。
这是个很严重的问题,因为老代码可能依赖于 push_back
提供的强烈的异常安全保证。因此,C++11 版本的实现不能简单的将 push_back
里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,唯一的副作用就是性能得到提升。
std::vector::push_back
受益于“如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98 中还有一些函数(如 std::vector::reverse
,std::deque::insert
等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用 C++11 的移动操作替换 C++98 的复制操作才是安全的。但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为 noexcept
。(这个检查非常弯弯绕。像是 std::vector::push_back
之类的函数调用 std::move_if_noexcept
,这是个 std::move
的变体,根据其中类型的移动构造函数是否为 noexcept
的,视情况转换为右值或保持左值(参见 Item23)。反过来,std::move_if_noexcept
查阅 std::is_nothrow_move_constructible
这个 type trait,基于移动构造函数是否有 noexcept
(或者 throw()
)的设计,编译器设置这个 type trait 的值。)
swap
函数是 noexcept
的另一个绝佳用地。swap
是 STL 算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的 swap
是否 noexcept
有时依赖于用户定义的 swap
是否 noexcept
。比如,数组和 std::pair
的 swap
声明如下:
template <class T, size_t N>
void swap(T (&a)[N],
T (&b)[N]) noexcept(noexcept(swap(*a, *b))); //见下文
template <class T1, class T2>
struct pair {
…
void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
noexcept(swap(second, p.second)));
…
};
这些函数 视情况noexcept
:它们是否 noexcept
依赖于 noexcept
声明中的表达式是否 noexcept
。假设有两个 Widget
数组,交换数组操作为 noexcept
的前提是数组中的元素交换是 noexcept
的,即 Widget
的 swap
是 noexcept
。因此 Widget
的 swap
的作者决定了交换 widget
的数组是否 noexcept
。对于 Widget
的交换是否 noexcept
决定了对于 Widget
数组的交换是否 noexcept
,以及其他交换,比如 Widget
的数组的数组的交换是否 noexcept
。类似地,交换两个含有 Widget
的 std::pair
是否 noexcept
依赖于 Widget
的 swap
是否 noexcept
。事实上交换高层次数据结构是否 noexcept
取决于它的构成部分的那些低层次数据结构是否 noexcept
,这激励你只要可以就提供 noexcept
swap
函数(译注:因为如果你的函数不提供 noexcept
保证,其它依赖你的高层次 swap
就不能保证 noexcept
)。
现在,我希望你能为 noexcept
提供的优化机会感到高兴,同时我还得让你缓一缓别太高兴了。优化很重要,但是正确性更重要。我在这个条款的开头提到 noexcept
是函数接口的一部分,所以仅当你保证一个函数实现在长时间内不会抛出异常时才声明 noexcept
。如果你声明一个函数为 noexcept
,但随即又后悔了,你没有选择。你可以从函数声明中移除 noexcept
(即改变它的接口),这理所当然会影响客户端代码。你可以改变实现使得这个异常可以避免,再保留原版本(现在来看不正确的)异常说明。如果你这么做,在异常试图离开这个函数时程序将会终止。或者你就顺从了既有实现,舍弃了激起你兴趣的东西,从一开始就改变实现。这些选择都不尽人意。
这个问题的本质是实际上大多数函数都是异常中立(exception-neutral)的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为 noexcept
,因为它们可能抛出那种“让它们过吧”的异常(译注:也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理异常。)因此大多数函数缺少 noexcept
设计。
然而,一些函数很自然的不应该抛异常,更进一步——尤其是移动操作和 swap
——使其 noexcept
有重大意义,只要可能就应该将它们实现为 noexcept
。(STL 对容器的移动操作的接口规范里缺少 noexcept
。然而实现者可以增强 STL 函数的异常说明,实际上,至少有些容器的移动操作已被声明为 noexcept
,这些做法就是本条例所给建议的好示例。发现了容器移动操作可以写成不抛异常的之后,实现者经常将这些操作声明为 noexcept
的,尽管标准并没有要求他们这么做。)老实说,当你确保函数决不抛异常的时候,一定要将它们声明为 noexcept
。
请注意我说有些函数有 自然的noexcept
实现法。为了 noexcept
而扭曲函数实现来达成目的是本末倒置。是把马车放到马前,是一叶障目不见泰山。是...选择你喜欢的比喻吧。(译注:几个英语熟语,都是想说明“本末倒置”。)如果一个简单的函数实现可能引发异常(比如调用一个可能抛异常的函数),而你为了讨好调用者隐藏了这个(比如捕获所有异常,然后替换为状态码或者特殊返回值),这不仅会使你的函数实现变得复杂,还会让调用点的代码变得复杂。调用者可能不得不检查状态码或特殊返回值。而这些复杂的运行时开销(比如额外的分支,大的函数给指令缓存带来的压力等)可能超出 noexcept
带来的性能提升,再加上你会悲哀的发现这些代码又难读又难维护。那是糟糕的软件工程化。
对于一些函数,使其成为 noexcept
是很重要的,它们应当默认如是。在 C++98,允许内存释放(memory deallocation)函数(即 operator delete
和 operator delete[]
)和析构函数抛出异常是糟糕的代码设计,C++11 将这种作风升级为语言规则。默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式 noexcept
。因此它们不需要声明 noexcept
。(这么做也不会有问题,只是不合常规)。析构函数非隐式 noexcept
的情况仅当类的数据成员(包括继承的成员还有继承成员内的数据成员)明确声明它的析构函数可能抛出异常(如声明“noexcept(false)
”)。这种析构函数不常见,标准库里面没有。如果一个对象的析构函数可能被标准库使用(比如在容器内或者被传给一个算法),析构函数又可能抛异常,那么程序的行为是未定义的。
值得注意的是一些库接口设计者会区分有宽泛契约(wild contracts)和严格契约(narrow contracts)的函数。有宽泛契约的函数没有前置条件。这种函数不管程序状态如何都能调用,它对调用者传来的实参不设约束。(“不管程序状态如何”和“不设约束”对已经行为未定义的程序无效。比如 std::vector::size
有宽泛契约,但是并不保证如果你把一块随机内存转换为一个 std::vector
,在这块内存上调用它会有合理的表现。转换的结果是未定义的,所以包含这个转换的程序也无法保证表现合理)宽泛契约的函数决不表现出未定义行为。
反之,没有宽泛契约的函数就有严格契约。对于这些函数,如果违反前置条件,结果将会是未定义的。
如果你写了一个有宽泛契约的函数并且你知道它不会抛异常,那么遵循这个条款给它声明一个 noexcept
是很容易的。对于严格契约的函数,情况就有点微妙了。举个例子,假如你在写一个形参为 std::string
的函数 f
,并且假定这个函数 f
很自然的决不引发异常。这就表明 f
应该被声明为 noexcept
。
现在假如 f
有一个前置条件:类型为 std::string
的参数的长度不能超过 32 个字符。如果现在调用 f
并传给它一个大于 32 字符的 std::string
,函数行为将是未定义的,因为 根据定义 违反了前置条件,导致了未定义行为。f
没有义务去检查前置条件,它假设这些前置条件都是满足的。(调用者有责任确保参数字符不超过 32 字符等这些假设有效。)即使有前置条件,将 f
声明为 noexcept
似乎也是合适的:
void f(const std::string& s) noexcept; //前置条件:
//s.length() <= 32
假定 f
的实现者在函数里面检查前置条件冲突。虽然检查是没有必要的,但是也没禁止这么做,检查前置条件可能也是有用的,比如在系统测试时。debug 一个抛出的异常一般都比跟踪未定义行为起因更容易。那么怎么报告前置条件冲突使得测试工具或客户端错误处理程序能检测到它呢?简单直接的做法是抛出“precondition was violated”异常,但是如果 f
声明了 noexcept
,这就行不通了;抛出一个异常会导致程序终止。因为这个原因,区分严格/宽泛契约库设计者一般会将 noexcept
留给宽泛契约函数。
作为结束语,让我详细说明一下之前的观察,即编译器不会为函数实现和异常规范提供一致性保障。考虑下面的代码,它是完全正确的:
void setup(); //函数定义另在一处
void cleanup();
void doWork() noexcept
{
setup(); //设置要做的工作
… //真实工作
cleanup(); //执行清理动作
}
这里,doWork
声明为 noexcept
,即使它调用了 non-noexcept
函数 setup
和 cleanup
。看起来有点矛盾,其实可以猜想 setup
和 cleanup
在文档上写明了它们决不抛出异常,即使它们没有写上 noexcept
。至于为什么明明不抛异常却不写 noexcept
也是有合理原因的。比如,它们可能是用 C 写的库函数的一部分。(即使一些函数从 C 标准库移动到了 std
命名空间,也可能缺少异常规范,std::strlen
就是一个例子,它没有声明 noexcept
。)或者它们可能是 C++98 库的一部分,它们不使用 C++98 异常规范,到了 C++11 还没有修订。
因为有很多合理原因解释为什么 noexcept
依赖于缺少 noexcept
保证的函数,所以 C++ 允许这些代码,编译器一般也不会给出 warnings。
请记住:
noexcept
是函数接口的一部分,这意味着调用者可能会依赖它noexcept
函数较之于 non-noexcept
函数更容易优化noexcept
对于移动语义,swap
,内存释放函数和析构函数非常有用- 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是
noexcept