用户登录
用户注册

分享至

Effective Modern C++ 条款14 把不发出异常的函数声明为noexcept

  • 作者: 镜子中裸体的我
  • 来源: 51数据库
  • 2021-09-02

把不发出异常的函数声明为noexcept

在c++98中,异常规范(exception specification)简直是只不靠谱的野兽。你必须总结一个函数可能发出的异常类型,因此如果修改了一个函数的实现,那么异常规范可能也需要改变。修改了异常执行顺序可能会打乱用户的代码,因为用户是根据异常规范来调用函数的。编译器通常不会在函数实现、异常规范、用户代码之间帮忙维护前后一致。最终结果是,大部分开发者都觉得异常规范(exception specification)不值得使用。

在制定c++11时,大家都同意需要一种明确有意义的通知来说明一个函数是否会发出异常。黑或白,函数要么可能发出异常,要么保证不发出异常。这种“可能或决不”两种情况形成了c++11异常规范的基础,从根本上替代c++98的异常规范(c++98风格的异常规范依然有效,但不赞成使用)。在c++11中,绝对的noexcept说明函数保证不会发出异常。

一个函数是否应该这样声明是接口设计的问题,函数发出异常这个行为是用户感兴趣的,用户可以询问函数的noexcept状态位,询问的结果会影响到代码的异常安全和高效性。一个函数的是否是noexcept的重要性相对于成员函数是否是const。如果你知道你的函数不会发出异常,却没有声明为noexcept,那么这个接口设计是不优秀的。

把不发出异常的函数声明为noexcpte还有一个额外的动机:它允许编译器生成更好的目标代码。想知道为什么,这有助于你检查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函数中,优化器不需要在异常出了函数时持有需展开的栈,也不用在异常离开对象时确保对象析构的顺序与构造循序相反。“throw()”函数就没有这种优化的灵活性,它做的与没有异常规范的函数一样,我们可以这样总结:

rettype function(params) noexcept; // 最大优化

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

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

这就足够说服你,当你知道函数不发出异常时,把他们声明为noexcept。

对于一些函数,更有说服力。移动构造就是一个优秀的例子。例如你在c++98旧代码中有std::vector,通过push_back来添加元素:

std::vector vw;

...

widget w;

... // 对w进行处理

vw.push_back(w); // 把w加入vw

...

假定代码运行得很好,然后你没有什么兴趣为c++11进行修改。不过,你肯定知道c++11中使用移动语义可以提供性能。如果你想确保widget有移动操作,要么你自己写,要么自动生成(请看条款17)。

当把一个新元素加入std::vector,可能std::vector会没有空间,那么此时就是std::vector的size与capacity相等啦。当出现这种情况,std::vector就会分配一个新的更大的内存来持有这些元素,然后当然要把元素从旧内存转移到新内存。在c++98中,这个转移是把每个元素都拷贝都新内存,然后销毁旧内存的元素。这种方法确保push_back函数提供了异常安全保证:如果在拷贝元素时抛出异常,std::vector的状态依旧不变,因为只有所有元素被拷贝到新内存后,才会销毁旧内存中的元素(即抛异常后旧内存仍有那些元素)。

在c++11中,一种很自然的优化就是把std::vector元素的拷贝替换成移动。但不幸的是,这样做有可能会违反push_back的异常安全保证。如果移动了n个元素,然后再移动第n+1个元素时抛出了异常,push_bcak操作就没有完成。但是原来的std::vector已经被改变了:n个元素被移动了。恢复成原来的状态是不可能的,因为试图把每个元素移动回旧内存可能还会抛出异常。

这是个严峻的问题,因为旧代码中的push_back的行为是具有异常安全保证的。因此,c++11中的push_back不能在暗地里把拷贝替换成移动,除非它知道移动构造不会发出异常。在这个例子中,使用移动替代拷贝是应该是安全的,但无法提高效率。

std::vector::push_back采用的策略是“move if you can, but copy if you must”(你可以用移动的话就移动,不行就一定要用拷贝),而且不止一个函数采用这个策略,在c++98中具有异常安全保证的函数也是这样做(例如,std::vector::reserve, std::deque::insert等)。所有这些函数在知道移动构造不会发出异常时,都会将c++98中调用的拷贝操作替换成c++11的移动操作。但是调用函数怎么知道它的移动操作不会产生异常呢?答案很明显啦:它会检查这个移动操作是否用noexcept声明。

swap函数也包含在上面的情况。swap是许多stl算法中的关键组成部分,它通常使用的是拷贝操作。它的广泛使用彰示着noexcept带来的优化十分值得。有趣的是,标准库中的swap是否是noexcept取决于用户定义swap是否为noexcept,例如,下面是标准库对数组和std::pair的swap:

template

void swap(t (&a)[n],

t (&b)[n]) noexcept(noexcept(swap(*a,*b))); // 详情看下面

template

struct pair {

...

void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&

noexcept(swap(second, p.second)));

...

};

这些函数带有额外的noexcept:函数是否是noexcept取决于从属的noexcept表达式是否是noexcept的。例如,有两个widget数组,如果交换单个元素的swap操作是noexcept的,那么交换整个数组也是noexcept的。写widget的swap函数的作者决定了交换widget数组是否为noexcept。相应地,也决定了一些swap是noexcept的,例如widget数组的数组的swap。同样地,交换两个含有widget的std::pair对象的swap函数也是noexcept的。交换高层次数据结构是noexcept的话,那么它们的低层次组成成员的交换肯定是noexcept的,这个事实激励你尽可能地提供noexcept的swap函数。

现在,我希望你能为noexcept带来的优化机会感到兴奋。不过,我要打击你的热情了。优化固然重要,那是正确性才是最重要的。我在本条款一开始就提到noexcept是函数接口的一部分,所以你只有在你愿意长期提供不发出异常的实现时,才把它声明noexcept。如果你声明一个函数是noexcept,后来后悔了,你觉得你一开始选择错了。那么你可以从函数声明那删除noexcept(就是改变接口),这样有可能破坏了用户的代码。你改变了实现,导致函数有可能会抛出异常,但是用户还保留着旧的异常规范。如果你真的这样做,那么当函数抛出异常时程序会被终止,那么你还要改代码吗。

事实上大部分函数是exception-neutral的。这些函数自身没有抛任何异常,不过它们调用的函数可能发出异常。如果调用的函数发出异常,那么exception-neutral函数允许异常沿着调用链通过。exception-neutral函数从来都不是noexcept的,因为它们可能会发出这种“只是通过一下”的异常,因此,大部分函数,普遍不适用noexcept。

但是一些函数,有很自然的noexcept实现,有几个——尤其是移动赋值操作和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区分函数。wide contract函数没有前提条件,不管程序处于什么状态都可以调用,也不会限制调用者传递给它的参数,而且wide contract函数从不呈现未定义行为。

没有wide contract的函数是narrow contract函数,这种函数如果违反了前提条件,结果将是未定义的。

如果你写的是wide contract函数,而且你知道它不会发出异常,根据本条款的建议,你可以很容易地把它声明了noexcept。对于narrow contract函数就比较复杂了。例如,你在写一个接受std::string作为参数的函数f,假如f的自然实现不会产生异常,那么建议把f声明为noexcept。

现在假如f函数有一个前提条件:参数std::string的长度不能超过32。如果f的参数std::string长度大于32,那么函数行为是未定义的,因为根据定义,违反前提条件会导致未定义行为。f没有义务检查参数是否符合前提条件,因为函数假定它们的前提条件是满足的(调用者有责任确保这个假定有效)。然后呢,就算有前提条件,把f声明为noexcept看起来也是合适的:

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

但是我们又假定f的实现者会去检查前提条件。虽然检查不是必需的,但它不会被禁止,然后检查前提条件也会有用,例如在测试的时候。调试一个被抛出的异常通常比追查未定义行为的原因要简单。但是违反前提条件应该怎样报告出来,以至于测试工具或者用户的错误回调函数发现它们呢?一个最直接的办法就是抛一个“违反前提条件”异常,但是f是用noexcept声明的,所以这个办法不行,抛出异常会导致程序终止。因为这个原因,区分wide contract和narrow contract函数的库设计者只会为wide contract保留noexcept。

最后一点,我会简单又详尽的说明,编译器通常不会帮助你识别函数实现和异常规范之间的不一致。看下面的代码,它是完全合法的:

void setup();

void cleanup();

void dowork() noexcept

{

setup();

...

cleanup();

}

在这里,dowork函数声明为noexcept,尽管它它用了不是noexcept的setup和cleanup函数。这看起来很矛盾,但是setup和cleanup的文档说明它们从不发出异常,尽管没声明为noexcept。例如,它们是c语言库的一部分(c标准库的代码移到std命名空间后都没有异常规范,例如,std::strlen没有声明为noexcept)。又或者它们是c++98库的一部分,它们没有使用c++98的异常规范,但又没有为c++11进行修正。

因为有合法的理由让noexcept函数调用没有noexcept的函数,所以c++允许这样的代码存在,然后编译器通常也不会对此发出警告。

总结

需要记住的4点:

noexcept是函数接口的一部分,这意味着调用者可能会依赖它。 noexcept函数会比非noexcept函数优化得更多。 noexcept对于移动赋值操作、swap、内存释放函数和析构函数具有重大的价值。大部分函数是exception-neutral函数,而不是noexcept函数。

软件
前端设计
程序设计
Java相关