什么时候应该真正使用noexcept?

720

noexcept关键字可以适用于许多函数签名,但我不确定在实践中何时应考虑使用它。根据我目前所了解的内容,noexcept的最后一分钟添加似乎解决了移动构造函数抛出异常时出现的一些重要问题。然而,我仍然无法对一些实际问题提供令人满意的答案,这些问题导致我首先阅读更多关于noexcept的内容。

  1. 有许多函数示例,我知道它们永远不会抛出异常,但编译器自己无法确定。在所有这样的情况下,我应该在函数声明中添加noexcept吗?

    必须在每个函数声明后考虑是否需要添加noexcept将大大降低程序员的生产力(并且说实话,这是一件麻烦的事情)。 在哪些情况下应更加谨慎地使用noexcept?哪些情况下可以使用隐含的noexcept(false)

  2. 在使用noexcept后,我真正能够期望观察到性能提升的情况是什么?具体来说,请给出一个示例代码,该C ++编译器能够在添加noexcept后生成更好的机器代码。

    个人而言,我关心noexcept是因为它提供给编译器更多自由地安全应用某些类型的优化。现代编译器是否利用了noexcept进行这种操作?如果没有,我是否可以期望其中一些编译器在不久的将来这样做?


48
如果代码中使用了move_if_nothrow(或类似的函数),并且存在一个noexcept移动构造函数,那么将会提高性能。 - R. Martinho Fernandes
3
好的,我将尽力以最简明扼要的方式进行翻译。以下是需要翻译的内容:相关链接:https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#Re-noexcept - moooeeeep
1
std::move_if_noexcept - TamaMcGlinn
9个回答

246

我认为现在给出“最佳实践”答案还为时过早,因为使用它的时间不够长。如果在throw specifiers刚出来后就问这个问题,那么答案会与现在非常不同。

每次在函数声明之后考虑是否需要附加noexcept将极大地降低程序员的生产力(而且,说实话,这将是一种痛苦)。

好吧,那么当函数永远不会抛出异常时,显然会使用它。

在使用noexcept后,我可以合理地期望观察到性能改进吗?[...] 就个人而言,我关注noexcept是因为它提供了更多的自由度,使编译器能够安全地应用某些优化。

似乎最大的优化收益来自用户优化,而不是编译器优化,因为有可能检查noexcept并对其进行重载。 大多数编译器都采用无惩罚的异常处理方法,因此我怀疑这不会改变您代码的机器码级别上的任何东西,尽管可能通过删除处理代码减小二进制大小。

在big four(构造函数,赋值运算符,而不是析构函数,因为它们已经noexcept)中使用noexcept可能会导致最佳改进,因为noexcept检查在模板代码中“常见”,例如,在std容器中。例如,只有当标记为noexcept(或编译器可以推断出其他方式)时,std::vector才会使用您的类的move。


8
我认为std::terminate技巧仍遵循零成本模型。也就是说,noexcept函数中的指令范围映射到调用std::terminate,如果使用throw而不是堆栈展开器,则会发生这种情况。因此,我怀疑它的开销比常规异常跟踪还要大。 - Matthieu M.
4
看这个:https://dev59.com/vGkw5IYBdhLWcg3wHW3n#10128180 实际上它只需要是非抛出的,但是 noexcept 保证了这一点。 - Pubby
3
如果一个noexcept函数抛出异常,std::terminate会被调用,这似乎会涉及一小部分开销。但实际上,应该通过不为这样的函数生成异常表来实现,然后由异常分发程序捕获并退出。 - Potatoswatter
9
C++的异常处理通常不会增加额外开销,除了跳转表(jump table)用于将可能抛出异常的调用地址映射到处理程序入口点。删除这些表就相当于完全删除异常处理。唯一的区别是可执行文件的大小。可能没有必要提及任何内容。 - Potatoswatter
36
“那么,当函数显然不会抛出异常时,请使用它。”我不同意。 noexcept 是函数的一部分 接口; 您不应该仅因为当前的 实现 不会引发异常就添加它。 我不确定这个问题的正确答案,但我非常有信心,您的函数今天的行为与此无关... - Nemo
显示剩余13条评论

178

如我近日一直反复强调的:首先是语义

添加noexceptnoexcept(true)noexcept(false)首要考虑的是语义,它们仅会附带一些可能的优化条件。

对于读代码的程序员来说,noexcept的存在就像const一样,有助于更好地理解哪些事情可能发生或不可能发生。因此,值得花一些时间思考函数是否会抛出异常。提醒一下,任何类型的动态内存分配都可能抛出异常。


好了,现在来看看可能的优化。

最明显的优化实际上是由库执行的。C++11提供了许多特性,可以知道一个函数是否是noexcept,标准库实现将使用这些特性来尽可能地支持用户定义的对象上的noexcept操作,例如移动语义

编译器只能从异常处理数据中削减一点点冗余(也许),因为它必须考虑到你可能会撒谎。如果标记为noexcept的函数确实抛出异常,则会调用std::terminate

选择这些语义有两个原因:

  • 即使依赖项尚未使用noexcept,也能立即从中受益(向后兼容)
  • 在调用可能理论上会抛出异常但对于给定的参数不应该抛出异常的函数时,允许指定noexcept

3
也许我太天真了,但我认为只调用noexcept函数的函数不需要执行任何特殊操作,因为任何可能引发异常的情况都会在到达这个级别之前触发terminate。这与处理和传播bad_alloc异常有很大不同。 - user1084944
9
可以按照您的建议定义noexcept,但这将是一个几乎无法使用的特性。许多函数可能会在某些条件不满足时抛出异常,即使您知道这些条件已经满足,也无法调用这些函数。例如,任何可能抛出std::invalid_argument的函数。 - tr3w
5
虽然回复有些晚,但还是来回答一下。标记为noexcept的函数可以调用其他可能会抛出异常的函数,但承诺不会自己抛出异常,也就是说它们只需要自行处理异常! - Honf
5
很久以前,我给这个答案点过赞,但我又读了一遍并思考了一下,现在有一个评论/问题。 "移动语义"是我曾经看到的唯一一个明显有用/是好主意使用 noexcept 的例子。我开始认为,移动构造、移动赋值和交换是唯一需要使用的情况......你知道还有其他情况吗? - Nemo
6
在标准库中,这可能是唯一的例子,但它展示了一种原则,可以在其他地方重复使用。移动操作是一种将某些状态暂时置于“悬空”状态的操作,只有当它具有 noexcept 特性时,才能够放心地在之后可能访问该数据的情况下使用它。我认为这个想法可以在其他地方使用,但是C ++的标准库相对较薄,并且它只用于优化元素的复制。 - Matthieu M.
显示剩余4条评论

113

实际上,这可能会对编译器中的优化产生巨大影响。多年来,编译器通过在函数定义后使用空throw()语句以及专有扩展已具备此功能。我可以向您保证,现代编译器确实利用此知识生成更好的代码。

几乎所有编译器中的优化都使用所谓的函数“流图”来推断什么是合法的。流图由函数的“块”(具有单个入口和单个出口的代码区域)以及块之间的边组成,指示流可以跳转到哪里。Noexcept会改变流图。

您要求一个具体的示例。请考虑以下代码:

void foo(int x) {
    try {
        bar();
        x = 5;
        // Other stuff which doesn't modify x, but might throw
    } catch(...) {
        // Don't modify x
    }

    baz(x); // Or other statement using x
}

如果bar被标记为noexcept,则此函数的流程图将不同(执行无法在bar结尾和catch语句之间跳转)。当标记为noexcept时,编译器确定在baz函数期间x的值为5——x=5块被称为“支配”baz(x)块,没有从bar()到catch语句的边缘。然后,它可以执行称为“常量传播”的操作以生成更有效的代码。在这里,如果内联了baz,则使用x的语句也可能包含常量,那么曾经是运行时评估的内容可以变成编译时评估等等。总之,简短的答案是:noexcept使编译器生成更紧凑的流程图,并且该流程图用于推理各种常见的编译器优化。对于编译器来说,用户的此类注释非常棒。编译器会尝试弄清楚这些东西,但通常不能(所涉及的函数可能在另一个对象文件中,编译器无法看到,或者传递使用某些函数,这些函数不可见),或者即使它这样做了,也有一些微不足道的异常可能会被抛出,而你甚至不知道它,因此无法隐式地将其标记为noexcept(例如,分配内存可能会抛出bad_alloc异常)。

3
在实践中,这是否真的有所不同?这个例子是人为构造的,因为在 x = 5 之前根本不可能抛出异常。如果 try 块中的那部分有任何作用,那么这种推理就站不住脚了。 - Potatoswatter
7
我认为,在优化包含try/catch块的函数时,加上noexcept修饰符确实会有真正的影响。虽然我举的例子是刻意制造的,但并不全面。更重要的是,noexcept(就像之前的throw()语句一样)有助于编译器生成更小的流程图(边和块更少),这是它所进行的许多优化的基础部分。 - Terry Mahaffey
编译器如何识别代码可能会抛出异常?访问数组是否被视为可能的异常情况? - Tomas Kubes
6
如果编译器可以看到函数的主体,它可以查找显式的throw语句或者其他可能会抛出异常的操作,比如可能会抛出异常的new。如果编译器无法看到主体,则必须依赖于noexcept是否存在。通常纯粹的数组访问不会生成异常(C++没有边界检查),所以数组访问本身不会导致编译器认为函数会抛出异常。(越界访问是未定义行为,而非保证会抛出异常。) - cdhowie
@cdhowie 说:"它必须依赖于 noexcept 的存在或不存在" 或者 throw() 在 C++ 的“noexcept”之前的存在。 - curiousguy
@TerryMahaffey "优化包含try/catch块的函数 " ... 完全处理异常,而不是像Java中的“finally”块那样重新抛出异常,这在库代码中占了99%的try/catch。 - curiousguy

69

noexcept 可以显著提高某些操作的性能。这并不是通过编译器生成机器代码的层面实现的,而是通过选择最有效的算法实现的:正如其他人所提到的,您可以使用函数 std::move_if_noexcept 进行此选择。例如,std::vector 的增长(例如,当我们调用 reserve 时)必须提供强异常安全保证。如果它知道 T 的移动构造函数不会抛出异常,它就可以只移动每个元素。否则,它必须复制所有的 T。这在这篇文章中已经详细描述。


6
补充说明:这意味着如果您定义了移动构造函数或移动赋值运算符,请在它们上面添加“noexcept”(如果适用)。隐式定义的移动成员函数会自动添加noexcept(如果适用)。 - mucaho

49
使用noexcept后,我应该合理期待什么时候会看到性能提升呢?请举一个例子来说明,在添加noexcept之后C++编译器能够生成更好的机器代码。
嗯,永远不会吧?永远是个时间点吗?永远。 noexceptconst一样,主要用于编译器性能优化。也就是说,几乎从不使用。 noexcept的主要作用是允许“你”在编译时检测函数是否可能抛出异常。记住:除非实际抛出异常,否则大多数编译器不会为异常发出特殊代码。因此,noexcept不是给编译器提示如何优化函数的问题,而是给“你”提示如何使用函数的问题。
move_if_noexcept这样的模板将检测移动构造函数是否定义为noexcept,如果没有,则返回该类型的const&而不是&&。这是一种说法,如果移动是非常安全的,则进行移动。
总的来说,当您认为使用它实际上将是有用的时候,请使用noexcept。某些代码将采用不同的路径,如果is_nothrow_constructible对于该类型为真,则会产生不同效果。如果您正在使用会这样做的代码,请随意使用noexcept适当的构造函数。
简而言之:在移动构造函数和类似结构中使用它,但不要感觉必须疯狂使用它。

15
严格来说,“move_if_noexcept”不会返回一个副本,而是返回一个const左值引用而不是右值引用。通常情况下,这将导致调用者进行复制而不是移动,但“move_if_noexcept”并没有执行复制操作。总之,解释很好。 - Jonathan Wakely
14
+1 Jonathan. 举个例子,调整一个向量的大小,如果移动构造函数是noexcept,则会移动对象而不是复制它们,因此“永远不会”是不正确的。 - mfontanini
4
我的意思是,在那种情况下编译器将生成更好的代码。OP正在寻找一个例子,证明编译器能够生成更优化的应用程序。尽管这不是一个“编译器”优化,但似乎是这样的。 - mfontanini
9
编译器生成更好的代码只因为编译器被迫编译了不同的代码路径。它之所以起作用,仅仅是因为std::vector被设计成强制编译器编译不同的代码。这不是关于编译器检测到某些东西,而是用户代码检测到某些东西。 - Nicol Bolas
4
@NicolBolas 我觉得你在争论语义。我知道编译器并不是生成更好代码的唯一因素。我也知道你想要表达的概念,而且在C++中争论语义并不是什么坏事。但是说实话,“move_if_noexcept”恰恰是问题所寻找的东西。如果严格区分“编译器”或“库”并不重要,因为从实际角度来看,在这种情况下,对于任何不实际“开发”C++标准库的用户来说,两者都是相同的。而这样的人几乎没有。 - Christian Rau
显示剩余5条评论

40
Bjarne的话中(《C++程序设计语言,第四版》,第366页):

Where termination is an acceptable response, an uncaught exception will achieve that because it turns into a call of terminate() (§13.5.2.5). Also, a noexcept specifier (§13.5.1.1) can make that desire explicit.

Successful fault-tolerant systems are multilevel. Each level copes with as many errors as it can without getting too contorted and leaves the rest to higher levels. Exceptions support that view. Furthermore, terminate() supports this view by providing an escape if the exception-handling mechanism itself is corrupted or if it has been incompletely used, thus leaving exceptions uncaught. Similarly, noexcept provides a simple escape for errors where trying to recover seems infeasible.

double compute(double x) noexcept
{
    string s = "Courtney and Anya";
    vector<double> tmp(10);
    // ...
}

The vector constructor may fail to acquire memory for its ten doubles and throw a std::bad_alloc. In that case, the program terminates. It terminates unconditionally by invoking std::terminate() (§30.4.1.3). It does not invoke destructors from calling functions. It is implementation-defined whether destructors from scopes between the throw and the noexcept (e.g., for s in compute()) are invoked. The program is just about to terminate, so we should not depend on any object anyway. By adding a noexcept specifier, we indicate that our code was not written to cope with a throw.


6
对我来说,这似乎意味着每次都应该添加“noexcept”,除非我明确想处理异常。让我们面对现实,大多数异常是如此不可预测和/或如此致命,以至于救援几乎是不合理或不可能的。例如,在引用的示例中,如果分配失败,应用程序几乎无法继续正常工作。 - Neonit
4
这可能是一个天真的问题,但为什么要关注vector<double> tmp(10);?如果上一行中的字符串实例创建时没有足够的内存,同样会抛出异常吗? - Daniel Daranas

30
  1. 有很多函数例子我知道不会抛出异常,但编译器无法自己判断。在这种情况下,我应该在所有函数声明中附加noexcept吗?

noexcept是棘手的,因为它是函数接口的一部分。特别是,如果你正在编写一个库,客户端代码可以依赖于noexcept属性。如果要更改它可能会很困难,因为您可能会破坏现有代码。当您正在实现仅由您的应用程序使用的代码时,这可能不太重要。

如果您有一个不会抛出异常的函数,请问自己它是否会始终保持noexcept,或者它会限制未来的实现?例如,您可能希望通过抛出异常(例如用于单元测试)引入非法参数的错误检查,或者您可能依赖于其他库代码,该代码可能更改其异常规范。在这种情况下,保守起见,最好省略noexcept

另一方面,如果您确信函数不会抛出异常并且它是规范的一部分,则应将其声明为noexcept。但请记住,如果您的实现发生更改,编译器将无法检测到noexcept的违规。

  1. 在哪些情况下应该更加谨慎地使用noexcept,在哪些情况下可以省略明确声明noexcept(false)

有四类函数应该特别关注,因为它们可能会产生最大的影响:

  1. 移动操作(移动赋值运算符和移动构造函数)
  2. 交换操作
  3. 内存释放器( operator delete,operator delete[])
  4. 析构函数(虽然这些隐式为noexcept(true),除非您将其设为noexcept(false)

这些函数通常应该使用noexcept,并且大多数库实现都可以利用noexcept属性。例如,std::vector 可以在不损失强异常保证的情况下使用非抛出移动操作。否则,它将不得不回退到复制元素(就像在 C++98 中一样)。

这种优化是在算法级别上进行的,不依赖于编译器优化。它可能会有显着的影响,特别是当元素很难复制时。

  1. 我什么时候可以现实地期望使用noexcept后能观察到性能改进?请给出一个具体的例子,说明在添加noexcept后C++编译器能够生成更好的机器代码。

noexcept相对于没有异常规定或throw()的优势在于标准允许编译器在堆栈展开方面有更大的自由度。即使是在throw()的情况下,编译器也必须完全展开堆栈(并且必须按照对象构造的完全相反顺序来进行展开)。

另一方面,在noexcept的情况下,不需要这样做。不存在必须展开堆栈的要求(但编译器仍然允许这样做)。这种自由度可以进一步优化代码,因为它降低了总是能够展开堆栈的开销。

有关必须进行堆栈展开时的开销的相关问题,请参见 noexept、堆栈展开和性能

我还推荐Scott Meyers的书《Effective Modern C++》,其中“第14项:如果它们不会引发异常,请声明函数为noexcept”可供进一步阅读。


如果C++像Java一样使用throws关键字标记可能抛出异常的方法,而不是使用noexcept否定关键字,那么这将更有意义。我只是无法理解C++的某些设计选择... - mip
他们将其命名为noexcept,因为throw已经被使用了。简单来说,throw可以用于几乎你提到的任何情况,除了它们设计得很糟糕,以至于它变得几乎无用 - 甚至是有害的。但现在我们被困在其中,因为删除它将是一项具有很少好处的破坏性更改。因此,noexcept基本上就是throw_v2 - AnorZaken
“throw” 怎么会没用呢? - curiousguy
1
@PhilippClaßen,“throw()”异常说明符并没有像“nothrow”一样提供相同的保证? - curiousguy
1
实际上,没有零开销的异常实现。Herb Sutter提出了一个建议,可以改变这种情况:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0709r0.pdf - Philipp Claßen
显示剩余4条评论

21
有很多函数的例子,我知道它们永远不会抛出异常,但编译器无法自行确定。在这种情况下,我应该在所有这些函数声明中添加 noexcept 吗?
当您说“我知道他们永远不会抛出异常”时,意思是通过检查函数的实现,您知道该函数不会抛出异常。我认为这种方法是内部的。
最好将考虑函数是否可能引发异常作为函数设计的一部分:与参数列表、方法是否为 mutator(…const)一样重要。声明“此函数永远不会抛出异常”是对实现的约束。省略它并不意味着该函数可能会抛出异常;它意味着当前版本和所有未来版本都可能抛出异常。这是一种使实现更加困难的约束。但某些方法必须具有约束才能实际上有用;最重要的是,它们可以从析构函数中调用,还可以用于提供强异常保证的方法的“回滚”代码的实现。

这是迄今为止最好的答案。您向用户保证了您的方法,这又意味着您将永远限制您的实现(除非进行破坏性更改)。感谢您启发我们的视角。 - AnorZaken
请参阅相关的Java问题 - Raedwald

3
这里有一个简单的例子,以说明在什么情况下它可能真正重要。
#include <iostream>
#include <vector>
using namespace std;
class A{
 public:
  A(int){cout << "A(int)" << endl;}
  A(const A&){cout << "A(const A&)" << endl;}
  A(const A&&) noexcept {cout << "A(const A&&)" << endl;}
  ~A(){cout << "~S()" << endl;}
};
int main() {
  vector<A> a;
  cout << a.capacity() << endl;
  a.emplace_back(1);
  cout << a.capacity() << endl;
  a.emplace_back(2);
  cout << a.capacity() << endl;
  return 0;
}

这是输出结果

0
A(int)
1
A(int)
A(const A&&)
~S()
2
~S()
~S()

如果我们在移动构造函数中删除noexcept,则输出如下:
0
A(int)
1
A(int)
A(const A&)
~S()
2
~S()
~S()

关键区别在于A(const A&&)A(const A&&)。在第二种情况下,它必须使用复制构造函数来复制所有的值。非常低效!!

看起来你正在使用这个例子:https://www.youtube.com/watch?v=AG_63_edgUg 我的问题是:使用“-fno-exceptions”编译器选项是否会触发与标记移动构造函数为“noexcept”相同的性能优势? - user643011
1
我刚刚使用GCC和clang trunk进行了测试,似乎即使使用-fno-execptions,标记函数为noexcept仍然是必需的,以便优先选择移动构造函数。 - user643011
1
你是指 A(const A&) vs A(const A&&) 吗?! - xyf
声明一个带有const修饰符的移动构造函数,这不是一个bug吗?你无法从一个常量对象进行移动。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接