前言
在C++98中,异常规格是一个很麻烦的事情。你必须时刻关注函数可能抛出的异常,一旦函数的实现发生改变,其异常规格也需要发生变化。轻易更改异常规格可能会破坏客户端代码,因为调用者可能依赖于原始的异常规格。编译器并不能帮助你维持函数实现、异常规格、客户端代码之间的一致性。大多数程序员都认为C++98的异常规格实在是太不友善了。
在长期的开发过程中人们逐渐产生了一项共识:函数的异常规格只需要告诉人们该函数是否可能会抛出一个异常即可。C++11以这种非黑即白式的异常规格取代了C++98中繁琐且易于出错的规格(不过它们现在仍然有效)。在C++11中,我们使用noexcept表征某个函数不会抛出任何异常。
noexcept声明
函数是否应当被声明为noexcept是一个接口设计问题。对于使用者来说,函数是否可能会抛出异常是一个关键问题。调用者理当了解被调用函数的异常状态,并且该状态会影响调用程序的异常安全性及效率。从这个角度而言,noexcept声明重要性不亚于const,如果一个不可能抛出异常的函数不声明为noexcept,那这个接口规范真是太失败了。
不过声明noexcept还有额外的目的:促使编译器生成更加恰当的目标码。假设当前存在一个函数f承诺不会抛出任何异常,那么它们在C++98与C++11中分别表示为:1
2int f(int x) throw(); // no exceptions from f: C++98 style
int f(int x) noexcept; // no exceptions from f: C++11 style
如果在运行期间f抛出了异常,违背了其异常规格,C++98异常规格会展开调用堆栈给f的调用者,并且在执行完一些与当前无关的操作后终止程序。C++11异常规格则保证堆栈仅可能展开于程序结束运行之前。
展开调用堆栈与可能展开调用堆栈对于代码生成有着极大影响,在noexcept函数中,如果异常传播到别处,那么优化器不需要保持运行期堆栈处于不可展开状态,也不必确保noexcept函数中的对象按照构造的相反顺序销毁。带有throw()或完全不具备异常规格的函数则不具备这种优化弹性:1
2
3RetType function(params) noexcept; // most optimizable
RetType function(params) throw(); // less optimizable
RetType function(params); // less optimizable
移动语义、性能优化与noexcept
在某些情况下,使用noexcept十分恰当,移动操作就是一个典型的例子。假设你有一个使用vector<Widget>的C++98代码库,widget通过push_back不断加入该vector::1
2
3
4
5
6std::vector<Widget> vw;
…
Widget w;
… // work with w
vw.push_back(w); // add w to vw
…
你希望利用C++11中的移动语义来提升程序性能,因此,你通过某种方式,例如自己撰写or自动生成(见Item17)来确保widget拥有移动操作。
当一个新元素被添加到一个std::vector时,std::vector可能没有空间(及size==cap)。当发生这种情况时,std ::vector会分配一个新的,更大的内存块来保存它的元素,并将内存中的元素从旧内存块传送到新的内存块。C++98将每个元素从旧内存复制到新内存,然后销毁旧内存中的对象。这种方法使push_back能够提供strong异常安全保证:如果在复制元素期间抛出异常,则std::vector的状态保持不变。因为在将所有元素成功复制到新内存之前,旧内存中的元素都不会被销毁。
C++11中存在一种针对上述情况的优化方法:以移动替换拷贝。不过这种优化策略破坏了push_back的异常安全保证,例如当前已完成n个元素的移动,但第n+1个元素在移动时抛出异常,此时push_back不得不被迫中止。在此情况下,原本的vector已经发生更改,并且将已经移动的元素再次移动回来并不现实,因为在此移动过程中可能会再次触发异常。因此,除非我们明确地了解移动操作不会抛出异常,否则C++11不会主动以移动代替拷贝。
std::vector::push_back采用的策略是”move if you can,but copy if you must”,并且它不是标准库中唯一采取此策略的函数,std::vector::reverse、std::deque::insert也是一样。当移动操作明确不会抛出异常时,这些函数会以移动操作代替C++98中性能低下的复制操作。那么这些函数如何了解移动操作是否可能会抛出异常呢?答案十分显然:通过检查移动操作是否具备noexcept声明。
swap与noexcept
swap是许多STL算法实现的关键组件,并且经常应用于对象的拷贝赋值运算符之中(见Effective C++ Item11)。由于它的广泛使用,因此对其使用noexcept是一件性价比极高的事情。有趣的是,标准库中swap的noexcept有时取决于用户自定义的swap是否noexcept。举例而言,标准库对于数组以及std::pair的swap函数有声明如下:1
2
3
4
5
6
7
8
9
10
11template <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。例如,假设当前存在两个vector<Widget>,swap这两个数组是否noexpt取决于swap其中单个元素是否这一行为(即swap Widget)是否noexcept。类似地,交换两个含有Widget的std::pair是否为noexcept亦取决于交换Widget是否noexcept。一言以蔽,交换高层数据数据这一行为是否noexcept取决于交换底层数据是否noexcept。
noexcept承诺
在讲优化之前,我们需要明确的是:优化固然重要,但程序的正确性更加重要,在前文中我们曾提及noexcept是接口的一部分,仅在你愿意付出长期维持某函数noexcept的代价时你才应当声明此函数为noexcept。如果你声明了一个函数noexcept然后发现noexcept无法维系,你的选择和下场十分惨烈:或冒着破坏客户端代码的风险从函数的声明中删除noexcept(即更改接口);或更改实现以使异常可以转义,从而保留原始但不正确的异常规格。
从实际情况而言,大部分函数都不会抛出异常,但其调用的子程序则未必尽然。当这种情况发生时,这些函数允许被抛出的异常越过自己直接到达调用链中的异常处理程序。这种不会自己抛出异常的函数不会被声明为noexcept,因为它们会抛出这种“just passing through”的异常。
但也存在一些函数,其本质而言不可能抛出异常,又或者其一旦不抛出异常会带来显著的性能提升,因此将其实现为noexcept性价比很高。如果你可以保证一个函数永不抛出异常,那你应当放心大胆地将其声明为noexcept。值得强调的是,我们这里所说的本质上不可能抛出异常,而非你通过种种手段强行吞下或处理了所有异常。
对于某些函数而言,其默认状态即为noexcept。在C++98中,允许内存释放函数(诸如operator delete、operator delete[])或析构函数抛出异常被视为非常恶劣的编码风格,而在C++11中,这几乎成为了一种语言规则:在默认情况下,所有的内存释放函数与析构函数均被隐式声明为noexcept(也就是说没必要去特意强调它们是noexcept,尽管没有任何坏处)。仅有在类的数据成员明确声明自身析构函数为noexcept(false)时类的析构函数才会不再隐式声明为noexcept。
wide contracts and narrow contracts
值得注意的是,某些库接口设计者将函数区分为”wide contracts”与”narrow contracts”。”wide contracts”函数没有任何先决条件,无论当前程序运行状态如何它都能够正常运行,且对传给它的参数没有任何限制,在任何情况下”wide contracts”均不会导致未定义行为。不具备”wide contracts”的函数被称为”narrow contracts”函数,对于此类函数,如果违反了其先决条件,则将触发未定义行为
如果你正在编写一个”wide contracts”函数且明确其不会抛出异常,那么自然可以轻易遵循本节原则将其声明为noexcept。对于”narrow contracts”则相对麻烦,举例而言,假设当前你正在编写一个函数f,其参数为std::string,理论上其不会抛出异常,这意味着f应当被声明为noexcept。现在我们假设f存在一个先决条件:string对象的长度不得超过32个字符,一旦超过则触发未定义行为。f并没有义务检查其先决条件是否满足,因为那是调用者该做的事。即使当前函数存在一个先决条件,将f声明为noexcept似乎也没毛病:1
void f(const std::string& s) noexcept; // precondition: s.length() <= 32
但假设当前f的实现者决定检查其先决条件是否违背,那么如何在发现先决条件不成立的情况下向测试程序或客户端处理发出报告?一种直接方法是抛出异常,但我们已经声明该函数为noexcept了,抛出异常只会导致程序直接终止。出于这个理由,库设计者通常仅仅保留”wide contracts”函数的noexcept属性。
函数实现与异常规格
编译器通常无法识别函数实现与其异常规格之间的不一致。考虑这一段完全合法的代码:1
2
3
4
5
6
7void setup(); // functions defined elsewhere
void cleanup();
void doWork() noexcept{
setup(); // set up work to be done
… // do the actual work
cleanup(); // perform cleanup actions
}
显然,noexcept函数doWork调用了non-except setup、cleanup。看起来很矛盾,但这并不意味着程序必然存在问题。例如,setup与cleanup在其文档中表示不会抛出异常,只不过它们没有用noexcept声明。又或者它们是用C语言编写的程序库的一部分(即使是已经移入std命名空间的C标准库中函数也没有声明异常规格,例如,std::strlen未声明为noexcept)。又或者它们是C++98库的一部分,刚刚决定不再使用C++98中的异常规格,并且尚未来得及针对C++11作出修订。
由于上述种种原因,C++允许noexcept函数调用non-noexcept函数,并且编译器不会对此发出任何警告。
总结
- noexcept是函数接口的一部分。
- noexcept函数比non-noexcept函数具备更高的优化弹性。
- noexcept对移动操作,交换,内存释放函数及析构函数性价比极高。
- 大部分函数都是excepttion—neutral而非noexcept。