使用Noexcept以表明函数不会抛异常

本文最后更新于:August 19, 2022 pm

本系列博客着重讨论现代C++在C++98上做出的更为细节的改动,本篇主要讨论noexcept关键字

在C++98中,如果要为函数添加异常声明,会有诸多麻烦。首先开发者要总结这个函数可能抛出的异常类型,将之放到函数签名中。如果函数体在后面的工作中被修改了,那么异常类型列表也可能需要重新修改,并且这还涉及到对函数调用者的代码修改,因为调用的上游很可能针对此函数的异常声明进行了一些定制操作。不幸的是,编译器通常不会对函数体的一致性维护做出任何帮助,总而言之,在那个时代,大多数开发者认为异常声明带来的麻烦远大于它带来的好处。

因此C++委员会在商量C++11标准时,首先明确了一点:对于一个函数而言,真正有用的信息不是它会抛哪些异常,而是它会不会抛出异常。这种非黑即白的异常声明成为了C++11的解决方案,过去的声明方式仍然有效,但是已经弃用了。

无条件的noexcept

在函数末尾添加noexcept关键字即可表明这个函数无论如何都不会抛出异常,尽管是否加上这个关键字看上去只是一个接口设计的问题。

编译器可以对声明为noexcept的函数生成更好地目标代码,为什么呢?这还要从C++98和C++11的不同处理方式说起

1
2
3
4
5
int f(int x) thow();
// C++98 style

int f(int x) noexcept;
// C++11 style

假设在运行时,f函数内部抛出了异常,如果是C++98版本的目标代码,它会保证此时f函数运行时栈会解退(stack unwinding)至f函数的调用者,然后在一些其它的的过程后,程序终止。而C++11版本的目标代码并没有此保证,调用者的调用栈仅仅是可能被解退。这里的可能,两字之差,对目标代码的影响非常大。在noexcept的函数内部,优化器不需要因为堤防可能会传播到函数外部的异常而时刻保持运行时栈处在一个可被解退的状态,也不需要保证在此函数内部的对象按照构造的顺序逆序被析构。C++98式的声明就没有这种灵活性。

1
2
3
4
5
6
7
8
RetType function (params) noexcept;
// 最大的优化空间

RetType function (params) throw();
// 更少的优化空间

RetType function (params);
// 更少的优化空间

单凭这一点其实就能打动我们,让在编程时将不会抛异常的函数声明为noexcept了,但其实还有更多的好处。对于某些函数来说,noexcept显得十分重要。

C++11所标榜的移动语义离不开移动构造和移动赋值,它俩就是典型的例子。

假设我们有一个容器std::vector<std::string>>,随着不断的push_back操作,容器的size达到capacity,需要进行扩容操作。在C++98中,此容器会重新申请一个更大的空间,然后将原来的对象一一拷贝过去,最后将原来的空间释放。这种拷贝的方式会使得push_back具有强异常约束:如果拷贝某个元素的过程中出现了异常,大可直接将新的空间释放掉,同时此容器的状态并不会发生改变。如果没有异常发生,那万事大吉,扩容成功,新的元素也被加入到容器尾部。

迁移到C++11之后,一个自然而然的想法就是在扩容时,将元素从旧的空间“搬”到新的空间的过程中,使用移动操作替换掉拷贝操作。不幸的是,这样做会潜在性地破坏push_back的异常约束:如果在移动第$n+1$个元素的过程中抛出了异常,push_back就进退两难了:前$n$个元素已经从旧的空间移动到新的空间,即使将新的空间释放,容器的状态也被改变;同时我们也不可能重新将前$n$个元素从新的空间移动回旧的空间,因为这个过程可能也会产生异常。

旧的代码很可能对push_back的强异常约束有依赖,因此C++11的实现不能默认使用移动操作替换拷贝操作,除非移动操作不会抛出异常。

std::vector::push_back采用”move if you can, copy if you must“的策略,同此策略相同的函数还有诸如std::vector::reversestd::deque::insert等等。

条件性的noexcept

swap函数是另一个极端需求noexcept关键字的例子。swap被经常用于STL的算法实现中,以及拷贝赋值运算符。考虑到它的广泛使用,因此在noexcept上花点功夫是值得的。有趣的是,标准库里的swap函数究竟是不是noexcept函数,这要取决于用户定义的swap函数是不是noexcept的。比如,标准库中对于std::pair和内置数组的swap函数为:

1
2
3
4
5
6
7
8
template <class T, size_t N>
void swap(T(&a)[N], T(&b)[Ns]) 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的时候,这些swap函数才是noexcept的。给定一个Widget类的数组,比如Widget[10],这个数组的swap函数是否是noexcept,要取决于Widget类对象之间的swap是否是noexcept的,也就是说Widget类的作者就已经决定了它的内置数组的swap函数是否是noexcept的高级数据结构的swap的函数是否会抛出异常反而是依赖的更底层的数据结构,

至此,我们已经充分了解了标注了noexcept的函数在性能上的优势是值得追求的,诚然,性能和优化很重要,但是准确性一定是更重要的。考虑到noexcept是函数签名的一部分,因此只有当我们希望长久地维护这个不会抛出异常的实现时才应当将至声明为noexcept。如果项目进展到一半,我们后悔将noexcept写在函数签名中了,届时几乎就没有回头路了。如果简单地将noexcept从签名中删去,那可能打破调用者对此noexcept约束的依赖;如果简单地保留noexcept的关键字,但是在函数实现上允许异常离开此函数,那么当此函数真正产生异常时,整个程序就终止了;或者继续忍气吞声,后悔当初为什么要将这个函数声明成noexcept。如此种种,都不是令人满意的方案。

产生这种现象的根源是,大多数函数对异常是持的中立态度。这种函数自己一般不会抛出异常,但是它们调用的子函数可能会。当子函数抛出异常时,异常也能被允许顺着调用链继续向上传递。这种函数也很难称得上是noexcept的,因为它们不生产异常,它们只是异常的搬运工。事实上,大多数函数,本来就是缺乏noexcept所要求的天资的。

对于少部分函数,其天然的实现就不会抛出异常,并且对于更少部分函数而言——比如移动操作和swap,将它们做成noexcept会有很显著的提升,这就值得我们为之付出一些额外的努力了。

请注意这里说的是它们的实现处处透露着noexcept的气质,千万不要本末倒置,试图去矫揉造作地拼凑修改一个函数的实现,以将它声明为noexcept。如果一个直白的函数实现就是有可能抛出异常的——比如调用一个可能抛出异常的函数,如果我们仍然历经折磨,将这一切都对调用者屏蔽了(比如将异常全不捕获然后换成错误码,参考boost::asio返回的boost::system::error_code),这会两头都不讨好——同时复杂化调用者的代码,和我们自己的函数。调用者可能不得不对函数的错误码进行某种测试,这里付出的运行时开销(更多的分支,更大的函数对cache造成更大的压力等)可能完全盖过了将函数做成noexcept所带来的提升,而最终我们还得面对一堆更难理解和维护的代码。

对于某些函数而言,noexcept的约束尤其重要,因此它们默认情况下就是noexcept的。C++98中,内存的释放函数(operator delete/ operator delete[])、或者析构函数一般是允许抛出异常的。到了C++11,这种约束变得更强了——所有的内存释放函数和析构函数(不管是用户定义的还是编译器生成的)都是隐式的noexcept,因此也不需要额外为之声明。析构函数唯一可能不是隐式noexcept的情况是,此类的某个成员变量的析构函数显式声明了它自己的析构函数可能抛异常(声明为noexcept(false)),但是这种析构函数终究是少数。至少标准库里没有这样的类,如果标准库使用的某个类(如实例化后的容器)在析构时抛出了异常,程序就进入了Undefined Behavior状态。

一些库的设计者通过宽接口(wide contract)和窄接口(narow contract)来设计函数,前一种类型的函数对参数没有额外要求,其运行和程序的状态也没有关系,它们不会导致未定义行为。如果是窄接口函数,它们对传入的参数则是有要求的,如果这个要求没有被满足,那么函数的执行结果就是Undefined Behavior。如果我们正在写一个宽接口的函数,并且我们知道它不会抛出异常,那么就可以采纳本条款的意见将之声明为noexcept;如果是窄接口的函数,那情况就微妙一些了。根据函数体的定义,当前提条件不满足时,函数的执行结果往往是未定义的。函数本身并没有责任来对参数进行检查,因为这通常是调用者的职责。

编译器支持

最后,有一段代码来阐明为什么编译器常常无法对noexcept的依赖约束提供帮助:

1
2
3
4
5
6
7
8
9
10
11
12
void setup();
void cleanup();

//...

void doWork() noexcept{
setup();

//......;

cleanup();
}

这一段代码是完全合法的,尽管doWork函数被声明为noexcept,但是它还是可以调用没有被声明为noexcept的函数。

这样做其实不无道理,setupcleanup函数可能在文档里注明了它们不会抛出异常,只是没有加上noexcept关键字。有人会问,为什么会有这种函数?其实还真有,并且有不少。

可能这是个C的函数:比如zookeeper的C client接口、pthread的库函数——即使那些从C标准库中搬到C++标准库中的函数也没有异常声明,比如std::strlen;又或者这是个C++98标准下的库函数(考虑到那个版本异常声明带来的痛苦,库的作者选择了不声明异常类型),但是还没有被升级为C++11……

因此,C++11以后,即使声明为noexcept的函数需要依赖没有noexcept声明的函数,这也是充分合理的,编译器往往不会给出warning

总结

noexcept是函数签名的一部分,这代表着函数的调用者可能对它有依赖

noexcept的函数比起普通函数更有优化空间

noexcept对于移动操作、swap函数和内存释放函数以及析构函数而言十分重要

大多数的函数都是异常中立的,而非noexcept的坚定支持者


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!