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风格的异常规范仍然有效,但已经被废弃掉了)。在C++ 11中,无条件的noexcept就是为保证保证不会抛出异常的函数准备的。

一个函数是不是应该这么声明(noexcept),事关接口的设计。函数是否会抛出异常的行为,是客户关注的核心。函数的异常抛出行为是客户最感兴趣的部分。调用者可以查询一个函数的noexcept状态,并且查询结果可以影响异常安全或者调用代码的性能。这么一来,一个函数是否用noexcept声明,就和成员函数是否用const声明具有同等重要的信息。当你知道一个函数不会抛出异常的时候却不声明它为noexcept,这就是糟糕的接口设计。

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

int f(int x) throw();        // no exceptions from f: C++98 style
int f(int x) noexcept;       // no exceptions from f: C++11 style

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

展开调用栈和可能展开调用栈,这两者之间的区别会对代码生成有很大的影响。在noexcept声明的函数中,如果异常将传播出函数,优化器不需要将运行时堆栈保持在展开状态;也不需要在异常逃出函数后,保证所有其中的对象以其构造的反序完成析构(译注:因为noexcept已经假设了不会抛出异常,所以就算异常被抛出,大不了就是程序终止,而不可能处理异常)。使用“throw()”异常规范的函数,以及没有异常规范的函数,就没有这样的优化灵活性。这些情况可以总结如下:

RetType function(params) noexcept;         // most optimizable
RetType function(params) throw();          // less optimizable
RetType function(params);                  // less optimizable

仅仅这个就已构成充足的理由,让你给任何已知的不会产生异常的函数加上noexcept声明了。
对于有些函数,情况更加典型。移动操作就是个很棒的例子。假设你有一份C++ 98风格的代码,它使用了std::vector。时不时的通过push_back将Widget加到std::vector中:

std::vector<Widget> vw;
// ...
Widget w;
// ...                            // work with w
vw.push_back(w);                  // add w to vw
// ...

假设这段代码工作得很好,你也没有兴趣把它改造成C++ 11版本。但你确实想基于C++ 11的move语法能提升原来代码的性能的这个事实,做一些优化。因此,你要确保Widget有一个move operation,要么是你自己写的,要么是自动生成的(Item 17)。

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

在C++ 11中,一个很自然的优化是用移动来代替std::vector元素的拷贝。不幸行的是,这么做增加了违反push_back强异常安全保证的风险。如果n个元素已经从旧内存块中move出去了,在move第n+1个元素时,抛出一个异常,则push_back操作无法完成。但是原来的std::vector中的对象已经被修改:n个元素已经被move出去了。想要恢复到原来的状态是可能不行,因为想要把每个对象move回原始内存块中的这个操作也可能产生异常。

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

std::vector::push_back采取”能移动则移动,必须复制才复制“(move if you can, but copy if you
must)的策略,而这并不是标准库中唯一这样做的函数。在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],                                    // see
          T (&b)[N]) noexcept(noexcept(swap(*a, *b)));  // below
          
template <class T1, class T2>
struct 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的,就必须保证数组中的每个元素被swap是noexcept的(也就是用Widget来调用的swap是noexcept的)。因此,Widget的swap方法的作者决定了widget类型数组的swap行为是否是noexcept的。相似的,两个含有Widget的std::pair的swap行为是否为noexcept,也取决于Widget的swap是否为noexcept。高阶数据结构的swap行为要想为noexcept,仅当构建它的低阶数据结构具备noexcept性质时才成立。这鼓励你尽量提供noexcept swap函数。

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

事实上,大多数函数是异常中立的(exception-neutral)。这些函数自己不抛出异常,但是他们调用的函数可能会抛出异常。当这种情况发生时,异常中立函数允许被抛出的异常在传递过程中传递到调用链上更上层的处理程序。异常中立函数永远不具备noexcept性质,因为它们可能会抛出这种“路过”的异常。因此,大多数函数担不起noexcept的称号(这个地方翻译的很别扭,原文也没太看懂)。

然而,一些函数天生就不抛出异常,而对于另外一些函数(特别是move操作和swap函数)成为noexcept能获得很大的回报,只要有任何可能,它们都值得实现为noexcept【注4】。当你能很明确地说一个函数永远不应该抛出异常的时候,你应该明确地把这个函数声明为noexcept。
注4:标准库容器中,移动操作的接口规范并不带有noexcept声明。但是,各个实现者是允许为标准库函数加强异常规格的。而且,实践中,为至少一部分容器的移动操作加上noexcept声明是常见的。这样的实践印证了本条款的建议。只要发现容器的移动操作有可能写成不抛出异常的,实现者就往往会把这些操作加上noexcept声明,即使标准并不要求它们这样做。

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

对于某些函数,声明noexcept是如此之重要,所以它们默认就是noexcept的。C++ 98中,允许内存释放函数(即operator delete和 operator delete[])和析构函数抛出异常是极差的一种编码风格,而在C++ 11中,这种风格直接升级为一条语言规则。默认的,内存释放函数和所有的析构函数(无论是用户定义的,还是编译器自动生成的)都被隐式的声明为noexcept。因此不用刻意的声明它们为noexcept(这么做不会造成任何问题,但是没必要。)。析构函数没有被隐式的声明为noexcept的唯一情况,就是所在类中又数据成员(包括继承而来的成员,以及在其他数据成员中包含的数据成员)的类型显示地将其析构函数声明为可能抛出异常(比如,加上“noexcept(false)声明”)。这种析构函数是很少见的。标准库里一个也没有,而如果把一个带有能抛出异常的析构函数的对象用在标准库中(比如,这个对象在一个容器中或者这个对象被传给一个算法),那么程序的行为是未定义的。

值得注意的是,一些库接口的设计者将函数区分为带有宽松契约(wide contracts)和带有狭窄契约(narrow contracts)的。带有宽松契约的函数没有前置条件。这样的函数无论程序的状态如何都可以被调用,并且对传递给它的参数没有任何约束。带有宽松契约的函数从不会表现出未定义的行为。

对于带有狭窄契约的函数,如果前置条件被违反,则结果是未定义的。

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

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

void f(const std::string& s) noexcept;       // precondition:
                                             // s.length() <= 32

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

最后一点,让我详述一下我最早的观察,就是编译器一般不能帮助识别函数实现及其异常说明之间的不一致。考虑下面这段代码,它是完全合法的:

void setup();                     // functions defined elsewhere
void cleanup();
void doWork() noexcept
{
    setup();                     // set up work to be done// do the actual work
    cleanup();                   // perform cleanup actions
}

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

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

Things to Remember

  • noexcept是函数接口的一部分,这意味着调用方可能会依赖这个接口;
  • 相比于不带noexcept声明的函数,带有noexcept声明的函数有更多的机会得到优化;
  • noexcept对于move操作,swap,内存释放函数和析构函数最有用;
  • 大多数函数都是异常中立的,而不具备noexcept特性;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值