EffectiveModernCppChinese/3.MovingToModernCpp/item14.md
猫耳堀川雷鼓 b108e982a0
Update item14.md
2021-02-07 16:58:33 +08:00

16 KiB
Raw Blame History

条款十四:如果函数不抛出异常请使用noexcept

Item 14: Declare functions noexcept if they wont 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::vectorstd::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::reversestd::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::pairswap声明如下:

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的,即Widgetswapnoexcept。因此Widgetswap的作者决定了交换widget的数组是否noexcept。对于Widget的交换是否noexcept决定了对于Widget数组的交换是否noexcept,以及其他交换,比如Widget的数组的数组的交换是否noexcept。类似地,交换两个含有Widgetstd::pair是否noexcept依赖于Widgetswap是否noexcept。事实上交换高层次数据结构是否noexcept取决于它的构成部分的那些低层次数据结构是否noexcept,这激励你只要可以就提供noexcept swap函数(译注:因为如果你的函数不提供noexcept保证,其它依赖你的高层次swap就不能保证noexcept)。

现在,我希望你能为noexcept提供的优化机会感到高兴,同时我还得让你缓一缓别太高兴了。优化很重要,但是正确性更重要。我在这个条款的开头提到noexcept是函数接口的一部分,所以仅当你保证一个函数实现在长时间内不会抛出异常时才声明noexcept。如果你声明一个函数为noexcept,但随即又后悔了,你没有选择。你可以从函数声明中移除noexcept(即改变它的接口),这理所当然会影响客户端代码。你可以改变实现使得这个异常可以避免,再保留原版本(现在来看不正确的)异常说明。如果你这么做,在异常试图离开这个函数时程序将会终止。或者你就顺从了既有实现,舍弃了激起你兴趣的东西,从一开始就改变实现。这些选择都不尽人意。

这个问题的本质是实际上大多数函数都是异常中立(exception-neutral)的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为noexcept,因为它们可能抛出那种“让它们过吧”的异常(译注:也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理异常。)因此大多数函数缺少noexcept设计。

然而,一些函数很自然的不应该抛异常,更进一步——尤其是移动操作和swap——使其noexcept有重大意义,只要可能就应该将它们实现为noexceptSTL对容器的移动操作的接口规范里缺少noexcept。然而实现者可以增强STL函数的异常说明实际上至少有些容器的移动操作已被声明为noexcept,这些做法就是本条例所给建议的好示例。发现了容器移动操作可以写成不抛异常的之后,实现者经常将这些操作声明为noexcept的,尽管标准并没有要求他们这么做。)老实说,当你确保函数决不抛异常的时候,一定要将它们声明为noexcept

请注意我说有些函数有自然的noexcept实现法。为了noexcept而扭曲函数实现来达成目的是本末倒置。是把马车放到马前,是一叶障目不见泰山。是...选择你喜欢的比喻吧。(译注:几个英语熟语,都是想说明“本末倒置”。)如果一个简单的函数实现可能引发异常(比如调用一个可能抛异常的函数),而你为了讨好调用者隐藏了这个(比如捕获所有异常,然后替换为状态码或者特殊返回值),这不仅会使你的函数实现变得复杂,还会让调用点的代码变得复杂。调用者可能不得不检查状态码或特殊返回值。而这些复杂的运行时开销(比如额外的分支,大的函数给指令缓存带来的压力等)可能超出noexcept带来的性能提升,再加上你会悲哀的发现这些代码又难读又难维护。那是糟糕的软件工程化。

对于一些函数,使其成为noexcept是很重要的它们应当默认如是。在C++98允许内存释放memory deallocation函数operator deleteoperator 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函数setupcleanup。看起来有点矛盾,其实可以猜想setupcleanup在文档上写明了它们决不抛出异常,即使它们没有写上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