如果函数不会抛出异常就把它们声明为noexcept

Item 14:如果函数不会抛出异常就把它们声明为noexcept

Posted by boydfd on 2016-01-01 00:00:00 +0800

Recently by the same author:


大语言模型中一个调皮的EOS token

You may find interesting:


理解引用折叠

Item 28:理解引用折叠


理解引用折叠

Item 28:理解引用折叠

本文翻译自《effective modern C++》,由于水平有限,故无法保证翻译完全正确,欢迎指出错误。谢谢!

在C++98中,异常规范(exception specifications)是一个不稳定因素。你必须总结出一个函数可能会抛出的异常类型,所以如果函数的实现被修改了,异常规范可能也需要被修正。改变异常规范则又可能影响到客户代码,因为调用者可能依赖于原先的异常规范。编译器通常不会提供帮助来维护“函数实现,异常规范以及客户代码”之间的一致性。最终,大多数程序员觉得C++98的异常规范不值得去使用。

C++11中,对于函数的异常抛出行为来说,出现了一种真正有意义的信息,它能说明函数是否有可能抛出异常。是或不是,一个函数可能抛出一个异常或它保证它不会抛出异常。这种“可能或绝不”二分的情况是C++11异常规范的基础,这种异常规范从本质上替换了C++98的异常规范。(C++98风格的异常规范仍然是有效的,但是它们是被弃用了的。)在C++11中,无条件的noexcept就说明这个函数保证不会抛出异常。

在设计接口的时候,一个函数是不是应该这么声明(noexcept)是一个需要考虑的问题。函数的异常抛出行为是客户最感兴趣的部分。调用者能询问一个函数的noexcept状态,并且这个询问的结果能影响异常安全(exception safety)或着调用代码的性能。因此,一个函数是否是noexcept和一个成员函数是否是cosnt,这两个信息使同样重要。当你知道一个函数不会抛出异常的时候却不声明它为noexcept,就属于一个不好的接口设计。

但是,这里还有一个额外的动机让我们把noexcept应用到不会产生异常的函数上:它允许编译器产生更好的目标代码。为了理解为什么会这样,让我们检查一下C++98和C++11中,对于一个函数不会抛出异常的不同解释。考虑一个函数f,它保证调用者永远不会收到异常。两种不同的表示方法:

int f(int x) throw();			//C++98风格

int f(int x) noexcept;			//C++11风格

如果,运行时期,一个异常逃离了f,这违反了f的异常规范。在C++98的异常规范下,f的调用者的调用栈被解开了,然后经过一些不相关的动作,程序终止执行。在C++11的异常规范下,运行期行为稍微有些不同:调用栈只有在程序终止前才有可能被解开。

解开调用栈的时机,以及解开的可能性的不同,对于代码的产生有很大的影响。在一个noexcept函数中,如果一个异常能传到函数外面去,优化器不需要保持运行期栈为解开的状态,也不需要确保noexcept函数中的对象销毁的顺序和构造的顺序相反(译注:因为noexcept已经假设了不会抛出异常,所以就算异常被抛出,大不了就是程序终止,而不可能处理异常)。使用“throw()”异常规范的函数,以及没有异常规范的函数,没有这样的优化灵活性。三种情况能这样总结:

RetType function(params) noexcept;			//优化最好

RetType function(params) throw();			//没有优化

RetType function(params);					//没有优化

这种情况就能作为一个充足的理由,让你在知道函数不会抛出异常的时候,把它声明为noexcept。

对于一些函数,情况变得更加强烈(更多的优化)。move操作就是一个很好的例子。假设你有一份C++98代码,它使用了std::vector。Widget通过一次次push_back来加到std::vector中:

std::vector<Widget> vw;

...

Widget w;

...						//使用w

vw.push_back(w);		//把w加到vw中

...

假设这个代码工作得很好,然后你也没有兴趣把它改成C++11的版本。但是,基于C++11的move语法能提升原来代码的性能(当涉及move-enabled类型时)的事实,你想做一些优化,因此你要保证Widget有一个move operation,你要么自己写一个,要么用函数生成器来实现(看Item 17)。

当一个新的元素被添加到std::vector时,可能std::vector剩下的空间不足了,也就是std::vector的size等于它的capacity(容量)。当发生这种事时,std::vector申请一个新的,更大的内存块来保存它的元素,然后把原来的内存块中的元素,转移到新块中去。在C++98中,转移是通过拷贝来完成的,它先把旧内存块中的所有元素拷贝到新内存块中,再销毁旧内存块中的对象(译注:再delete旧内存)。这种方法确保push_back能提供强异常安全的保证:如果一个异常在拷贝元素的时候被抛出,std::vector的状态没有改变,因为在所有的元素都成功地被拷贝到新内存块前,旧内存块中的元素都不会被销毁。

在C++11中,会进行一个很自然的优化:用move来替换std::vector元素的拷贝。不幸的是,这样做会违反push_back的强异常安全保证。如果n个元素已经从旧内存块中move出去了,在move第n+1个元素时,有一个异常抛出,push_back操作不能执行完。但是原来的std::vector已经被修改了:n个元素已经被move出去了。想要恢复到原来的状态是不太可能的,因为尝试”把新内存块中的元素move回旧内存块中“的操作也可能产生异常。

这是一个严重的问题,因为一些历史遗留代码的行为可能依赖于push_back的强异常安全的保证。因此,除非知道它不会抛出异常,否则C++11中的push_back的实现不能默默地用move操作替换拷贝操作。在这种情况(不会抛出异常)下,用move替换拷贝操作是安全的,并且唯一的效果就是能提升代码的性能。

std::vector::push_back采取”如果可以就move,不能就copy“的策略,并且在标准库中,不只是这个函数这么做。在C++98中,其他提供强异常安全的函数(比如,std::vector::reserve,std::deque::insert等等)也采取这样的策略。如果知道move操作不会产生异常,所有这些函数都在C++11中使用move操作来替换原先C++98中的拷贝操作。但是一个函数怎么才能知道move操作会不会产生异常呢?回答很明显:它会检查这个操作是否被声明为noexcept。

swap函数特别需要noexcept,swap是实现很多STL算法的关键部分,并且它也常常被拷贝赋值操作调用。它的广泛使用使得noexcept提供的优化特别有价值。有趣的是,标准库的swap是否是noexcept常常取决于用户自定义的swap是否是noexcept。举个例子,标准库中,array和std::pair的swap这么声明:

template<class T, size_t N>
void swap(T (&a)[N],
		  T (&a)[N])	noexcept(noexcept(swap(*a, *b)));

template<class T1, class T2>
sturct pair{
	...
	void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && 
								noexcept(swap(second, p.second)));
	...
};

这些函数是条件noexcept(conditionally noexcept):它们是否是noexcept取决于noexcept中的表达式是否是noexcept。举个例子,给出两个Widget的数组,只有用数组中的元素来调用的swap是noexcept时(也就是用Widget来调用的swap是noexcept时),用数组调用的swap才是noexcept。反过来,这也决定了Widget的二维数组是否是noexcept。相似地,std::pair<Widget, Widget>对象的swap成员函数是否是noexcept取决于用Widget调用的swap是否是noexcept。事实上,只有低层次数据结构的swap调用是noexcept,才能使得高层次数据结构的swap调用是noexcept。这鼓励你尽量提供noexcept swap函数。

现在我希望你已经对noexcept提供的优化机会感到兴奋了。哎,可是我必须浇灭你的热情。优化很重要,但是正确性更重要。我记得在这个Item的开始说过,noexcept是函数接口的一部分,所以只有当你愿意长期致力于noexcept的实现时,你才应该声明函数为noexcept。如果你声明一个函数为noexcept,并且之后对于这个决定后悔了,你的选择是将是绝望的。1:你能把noexcept从函数声明中移除(也就是改变函数接口),则客户代码会遭受运行期风险。2:你也能改变函数的实现,让异常能够逃离函数,但是保持着原来的异常规范(现在,原来的规范声明是错误的)。如果你这么做,当一个异常尝试逃离函数时,你的程序将会终止。3:或者你可以抛弃一开始想要改变实现的想法,回归到你现存的实现中去。这些选择没有一个是好的选择。

事实上,很多函数都是异常中立的(exception-neutral)。这些函数自己不抛出异常,但是他们调用的函数可能抛出异常。当发生这样的事时,异常中立的函数允许异常通过调用链传给处理程序。异常中立的函数永远不是noexcept,因为他们可能抛出“我只经过一下”(异常产生的地方在别的函数中,但是需要经过我们来传递出去)的异常。因此,很大部分函数都不适合设计为noexcept。

然而,一些函数天生就不抛出异常,并且对于一些函数(特别是move操作和swap函数)成为noexcept能有很大的回报,只要有任何可能,它们都值得实现为noexcept。当你能很明确地说一个函数永远不应该抛出异常的时候,你应该明确地把这个函数声明为noexcept。

请记住,我说过一些函数天生就适合实现为noexcept。但是如果扭曲一个函数的实现来允许noexcept声明,这样是本末倒置的。假设一个简单的函数实现可能会产生异常(比如,它调用的函数可能抛出异常),如果你想隐藏这样的调用(比如,在函数内部捕捉所有的异常并且把它们替换成相应的状态值或者特殊的返回值)不仅将使你的函数实现更加复杂,它还将使你的函数调用变得更加复杂。举个例子,调用者必须要检查状态值或特殊的返回值。同时增加的运行期的费用(比如,额外的分支,以及更大的函数在指令缓存上会增加更大的压力。等等)会超过你希望通过noexcept来实现的加速,同时,你还要承担源代码更加难以理解和维护的负担。这真是一个糟糕的软件工程。

对于一些函数来说,声明为noexcept不是如此重要,它们在默认情况下就是noexcept了。在C++98中,允许内存释放函数(比如operator delete和operator delete[])和析构函数抛出异常是很糟糕的设计风格,在C++11中,这种设计风格已经在语言规则的层次上得到了改进。默认情况下,所有的内存释放函数和所有的析构函数(包括用户自定义的和编译器自动生成的)都隐式成为noexcept。因此我们不需要把它们声明成noexcept的(这么做不会造成任何问题,但是不寻常。)只有一种情况析构函数不是隐式noexcept,就是当类中的一个成员变量(包括继承来和被包含在成员变量中的成员变量)的析构函数声明表示了它可能会抛出异常(比如,声明这个析构函数为“noexcept(false)”)。这样的声明是不寻常的,标准库中就没有。如果把一个带有能抛出异常的析构函数的对象用在标准库中(比如,这个对象在一个容器中或者这个对象被传给一个算法),那么程序的行为是未定义的。

我们值得去注意一些库的接口设计区分了宽接口(wide contract)和窄接口(narrow contract)。一个带宽接口的函数没有前提条件。这样的函数被调用时不需要注意程序的状态,它在传入的参数方面没有限制。带宽接口的函数永远不会展现未定义行为。

不带宽接口条件的函数就是窄接口函数。对这些函数来说,如果传入的参数违反了前提条件,结果将是未定义的。

如果你在写一个宽接口的函数,并且你知道你不会抛出一个异常,那就遵循本Item的建议,把它声明为noexcept。对于那些窄接口的函数,情况将变得很棘手。举个例子,假设你正在写一个函数f,这个函数接受一个std::string参数,并且它假设f的实现永远不会产生一个异常。这个假设建议我们把f声明为noexcept。

现在假设f有一个前提条件:std::string参数的数据长度不会超过32个字节。如果用一个超过32字节的std::string来调用f,f的行为将是未定义的,因为一个不符合前提条件的参数会导致未定义行为。f没有义务去检查前提条件,因为函数假设它们的前提条件是被满足的(调用者有责任确保这些假设是有效的)。由于前提条件的存在,把f声明为noexcept看起来是合理的。

void f(const std::string& s) noexcept;		//前提条件:s.length() <= 32

但是假设f的实现选择检查前提条件是否被违反了。检查本不是必须的,但是它也不是被禁止的,并且检查一下前提条件是有用的(比如,在进行系统测试的时候)。调试时,捕捉一个抛出的异常总是比尝试找出未定义行为的原因要简单很多。但是要怎么报道出前提条件被违反了呢?只有报道了才能让测试工具或客户端的错误处理机制来捕捉到它。一个直接的方法就是抛出一个“前提条件被违反”的异常,但是如果f被声明为noexcept,那么这个方法就不可行了,抛出一个异常就会导致程序终止。因此,区分宽接口和窄接口的库设计者通常只为宽接口函数提供noexcept声明。

最后还有一点,让我完善一下我之前的观点(编译器常常无法对“找出函数实现和它们的异常规范之间的矛盾”提供帮助)。考虑一下下面的代码,这段代码是完全合法的:

void setup();			//在别处定义的函数
void cleanup();			

void doWork() noexcept
{
	setup();			//做设置工作

	...					//做实际的工作

	cleanup();			//做清理工作
}

在这里,尽管doWork调用了non-noexcept函数(setup和cleanup),doWork还是被声明为noexcept。这看起来很矛盾,但是有可能setup和cleanup在说明文档中说了它们永远不会抛出异常。就算它们没有在说明文档中说明,我们 还是有多理由来解释他们的声明式为什么是non-noexcept。举个例子,它们可能是用C写的。(也可能是从C标准库移动到std命名空间但缺少异常规范的函数,比如,std::strlen没有声明为noexcept)或者它们可能是C++98标准库的一部分,没有使用C++98的异常规范,并且到目前为止还没有被修改成C++11的版本。

因为这里有很多合适的理由来解释为什么noexcept函数可以调用缺乏noexcept保证的函数,所以C++允许这样的代码,并且编译器通常不会对此发出警告。

            你要记住的事
  • noexcept是函数接口的一部分,并且调用者可能会依赖这个接口。
  • 比起non-noexcept函数,noexcept函数可以更好地被优化。
  • noexcept对于move操作,swap,内存释放函数和析构函数是特别有价值的,
  • 大部分函数是异常中立的而不是noexcept。