27-9 异常规范和 noexcept
(感谢读者 Koe 提供本课初稿!)
从典型的函数声明来看,无法确定该函数是否会抛出异常:
int doSomething(); // can this function throw an exception or not?
在上面的例子中,this 是否会doSomething()抛出异常?这一点并不明确。但在某些情况下,答案至关重要。在第27.8 课——异常的危险和弊端中,我们描述了在栈展开期间从析构函数抛出的异常会导致程序停止运行。如果 thisdoSomething()会抛出异常,那么从析构函数(或任何其他不希望抛出异常的地方)调用它就是有风险的。虽然我们可以让析构函数处理 this 抛出的异常doSomething()(这样这些异常就不会传播到析构函数之外),但我们必须记住这样做,并且必须确保涵盖所有可能抛出的异常类型。
虽然注释可以帮助列举一个函数是否会抛出异常(如果会,抛出的是什么类型的异常),但文档可能会过时,而且编译器也不会强制要求使用注释。
异常规范是一种语言机制,最初设计用于在函数规范中记录函数可能抛出的异常类型。虽然大多数异常规范现在已被弃用或移除,但新增了一种有用的异常规范作为替代方案,我们将在本课中介绍它。
noexcept 说明符
在 C++ 中,所有函数都被分为非抛出异常函数和潜在抛出异常函数。非抛出异常函数承诺不会抛出调用者可见的异常。潜在抛出异常函数则可能抛出调用者可见的异常。
要将函数定义为不抛出异常的函数,我们可以使用noexcept说明符。为此,我们noexcept在函数声明中使用该关键字,并将其放在函数参数列表的右侧:
void doSomething() noexcept; // this function is specified as non-throwing
请注意,这noexcept实际上并不能阻止函数抛出异常或调用其他可能抛出异常的函数。只要 noexcept 函数在内部捕获并处理这些异常,并且这些异常不会导致 noexcept 函数退出,那么这样做就是允许的。
如果未处理的异常会导致 noexcept 函数退出,std::terminate则会调用 this.except()(即使堆栈上层存在其他异常处理程序可以处理此类异常)。如果std::terminate从 noexcept 函数内部调用 this.except(),则堆栈展开可能发生也可能不发生(取决于具体实现和优化),这意味着在函数终止之前,对象可能无法正确销毁。
关键见解:
noexcept函数承诺不抛出调用者可见的异常,这是一种契约式的承诺,而非编译器强制执行的承诺。因此,虽然调用noexcept函数本身应该是安全的,但任何导致契约被打破的异常处理错误都会导致程序终止!这种情况不应该发生,就像程序错误本身也不应该发生一样。
因此,最好让 noexcept 函数完全不处理异常,或者不调用可能抛出异常的函数。如果一开始就不可能抛出任何异常,那么 noexcept 函数就不可能存在异常处理错误!
就像只有返回值不同的函数不能重载一样,只有异常规范不同的函数也不能重载。
演示 noexcept 函数和异常的行为
以下程序演示了 noexcept 函数在各种情况下的行为以及异常处理:
// h/t to reader yellowEmu for the first draft of this program
#include <iostream>
class Doomed
{
public:
~Doomed()
{
std::cout << "Doomed destructed\n";
}
};
void thrower()
{
std::cout << "Throwing exception\n";
throw 1;
}
void pt()
{
std::cout << "pt (potentally throwing) called\n";
//This object will be destroyed during stack unwinding (if it occurs)
Doomed doomed{};
thrower();
std::cout << "This never prints\n";
}
void nt() noexcept
{
std::cout << "nt (noexcept) called\n";
//This object will be destroyed during stack unwinding (if it occurs)
Doomed doomed{};
thrower();
std::cout << "this never prints\n";
}
void tester(int c) noexcept
{
std::cout << "tester (noexcept) case " << c << " called\n";
try
{
(c == 1) ? pt() : nt();
}
catch (...)
{
std::cout << "tester caught exception\n";
}
}
int main()
{
std::cout << std::unitbuf; // flush buffer after each insertion
std::cout << std::boolalpha; // print boolean as true/false
tester(1);
std::cout << "Test successful\n\n";
tester(2);
std::cout << "Test successful\n";
return 0;
}
在作者的电脑上,该程序打印出:

然后程序中止了。
让我们更详细地探讨一下这里发生了什么。请注意,这tester是一个 noexcept 函数,因此承诺不会向调用者(main)暴露任何异常。
第一个例子说明了 noexcept 函数可以调用可能抛出异常的函数,甚至可以处理这些函数抛出的任何异常。首先,tester(1)被调用了,它调用了可能抛出异常的函数 pt,该函数又调用了 thrower,最终抛出了一个异常。该异常的第一个处理程序位于tester中,因此异常会展开堆栈(doomed在此过程中销毁局部变量),并且该异常在 tester 中被捕获并处理。由于tester不会将此异常暴露给调用者( main),因此这里没有违反 noexcept 规则,控制权返回给main。
第二个例子说明了当一个 noexcept 函数试图将异常传递回其调用者时会发生什么。首先,tester(2)被调用了,它调用了不抛出异常的函数 nt,nt 又调用了 thrower,而 thrower 抛出了一个异常。该异常的第一个处理程序位于tester中。然而,nt 是 noexcept 的,为了到达 tester中的处理程序,异常必须传播到 nt 的调用者。这违反了 nt 的 noexcept 规则,因此调用了 std::terminate,程序立即中止。在作者的机器上,堆栈没有被展开(如未销毁std::terminate所示)。
带有布尔参数的 noexcept 说明符
该noexcept说明符有一个可选的布尔参数。noexcept(true)等价于 noexcept,表示该函数不会抛出异常。noexcept(false)表示该函数可能会抛出异常。这些参数通常仅用于模板函数,以便可以根据某些参数值动态创建不会抛出异常或可能会抛出异常的模板函数。
哪些函数不会抛出异常,哪些函数可能会抛出异常?
隐式不抛出异常的函数:
- 析构函数
默认情况下不会抛出异常的隐式声明函数或默认函数:
- 构造函数:default、copy、move
- 赋值: copy、move
- 比较运算符(自 C++20 起)
但是,如果这些函数中的任何一个(显式或隐式地)调用了另一个可能抛出异常的函数,那么被调用的函数也会被视为可能抛出异常。例如,如果一个类的数据成员的构造函数可能抛出异常,那么该类的构造函数也会被视为可能抛出异常。再举一个例子,如果一个复制赋值运算符调用了一个可能抛出异常的赋值运算符,那么该复制赋值运算符也会被视为可能抛出异常。
可能抛出异常的函数(如果未隐式声明或未设置默认值):
- 正常函数
- 用户自定义构造函数
- 用户自定义运算符
noexcept 运算符
noexcept 运算符也可以在表达式内部使用。它接受一个表达式作为参数,并返回true一个值,false表示编译器认为该表达式是否会抛出异常。noexcept 运算符在编译时进行静态检查,实际上并不计算输入表达式的值。
void foo() {throw -1;}
void boo() {};
void goo() noexcept {};
struct S{};
constexpr bool b1{ noexcept(5 + 3) }; // true; ints are non-throwing
constexpr bool b2{ noexcept(foo()) }; // false; foo() throws an exception
constexpr bool b3{ noexcept(boo()) }; // false; boo() is implicitly noexcept(false)
constexpr bool b4{ noexcept(goo()) }; // true; goo() is explicitly noexcept(true)
constexpr bool b5{ noexcept(S{}) }; // true; a struct's default constructor is noexcept by default
noexcept 运算符可用于根据代码是否可能抛出异常来有条件地执行代码。这是为了满足某些异常安全保证,我们将在下一节中讨论这些保证。
异常安全保证
异常安全保证是一种契约性准则,它规定了当发生异常时函数或类的行为方式。异常安全保证分为四个级别:
- 无法保证——如果抛出异常,无法保证会发生什么(例如,类可能处于无法使用的状态)。
- 基本保证——如果抛出异常,不会发生内存泄漏,对象仍然可用,但程序可能会处于修改后的状态。
- 强保证——如果抛出异常,不会发生内存泄漏,程序状态也不会改变。这意味着函数要么完全成功,要么即使失败也不会产生任何副作用。如果失败发生在任何修改之前,这很容易做到;但也可以通过回滚所有更改,使程序恢复到失败前的状态来实现。
- 无抛出/无失败保证——该函数要么始终成功(无失败),要么始终失败且不抛出任何对调用者可见的异常(无抛出)。如果未向调用者公开异常,则异常可能会在内部抛出。此noexcept说明符对应于此级别的异常安全保证。
让我们更详细地看一下不抛出异常/不失败的保证:
不抛出异常保证:如果一个函数执行失败,它不会抛出异常。相反,它会返回一个错误代码或忽略该问题。在栈展开期间,当异常已被处理时,必须遵循不抛出异常保证;例如,所有析构函数都应该遵循不抛出异常保证(析构函数调用的所有函数也应该遵循该保证)。以下是一些应该遵循不抛出异常保证的代码示例:
- 析构函数和内存释放/清理函数
- 更高层无抛出函数需要调用的函数
无失败保证:一个函数总是能成功完成它想要执行的操作(因此永远不需要抛出异常,所以,无失败保证比不抛出异常保证更强)。以下是一些应该保证无失败的代码示例:
- 移动构造函数和移动赋值(移动语义,第 22 章将介绍)
- 交换函数
- 容器的清除/擦除/重置功能
- 对 std::unique_ptr 的操作(第 22 章也有介绍)
- 更高层级的无失败函数需要调用的函数
何时使用 noexcept
即使你的代码没有显式抛出任何异常,也不意味着你应该随意抛出noexcept异常。默认情况下,大多数函数都可能抛出异常,因此如果你的函数调用了其他函数,那么它很可能调用了一个可能抛出异常的函数,从而也可能导致你的函数抛出异常。
将函数标记为非抛出异常有几个很好的理由:
- 可以从非异常安全的函数(例如析构函数)中安全地调用不抛出异常的函数。
- 使用
noexcept函数可以让编译器执行一些原本无法实现的优化。由于noexcept函数不能在函数外部抛出异常,编译器无需担心运行时堆栈处于可展开状态,这可以使其生成更快的代码。 - 在某些情况下,知道一个函数是 noexcept 的,可以帮助我们在自己的代码中实现更高效的函数:标准库容器(例如std::vector)能够感知 noexcept,并会使用 noexcept 运算符来确定在某些情况下是使用move semantics(更快的)还是copy semantics(更慢的)。我们在第 22 章中介绍了移动语义,并在第 27.10 课中介绍了这种优化——std::move_if_noexcept。
noexcept标准库的策略是仅对那些不能抛出异常或失败的函数使用。那些可能抛出异常但实际上(由于实现原因)不会抛出异常的函数通常不会被标记为noexcept。
对于您自己的代码,请始终将以下内容标记为noexcept:
- 移动构造函数
- 移动赋值运算符
- 交换函数
对于您的代码,请考虑将以下内容标记为noexcept:
- 对于您希望表达不抛出异常或不失败保证的函数(例如,为了说明它们可以从析构函数或其他 noexcept 函数中安全调用),请使用以下代码:
- 复制构造函数和复制赋值运算符不会抛出异常(以便利用优化)。
- 析构函数。只要所有成员都有 noexcept 析构函数,析构函数就隐式地是 noexcept 的。
最佳实践:
始终创建移动构造函数、移动赋值函数和交换函数noexcept。
noexcept尽可能创建复制构造函数和复制赋值运算符。
在其他函数中使用noexcept,表示不会失败或不会抛出异常的保证。
最佳实践:
如果您不确定某个函数是否应该保证不失败/不抛出异常,请谨慎行事,不要使用noexcept标记它noexcept。撤销使用noexcept的决定会违反对用户关于函数行为的接口承诺,并可能破坏现有代码。之后通过向最初未使用noexcept的函数添加noexcept来加强保证被认为是安全的。
动态异常规范
可选阅读材料
在 C++11 之前,直到 C++17,动态异常规范被用来代替 noexcept。动态异常规范语法使用throw关键字来列出函数可能直接或间接抛出的异常类型:
int doSomething() throw(); // does not throw exceptions
int doSomething() throw(std::out_of_range, int*); // may throw either std::out_of_range or a pointer to an integer
int doSomething() throw(...); // may throw anything
由于编译器实现不完善、与模板函数存在一些不兼容性、人们对动态异常的工作原理普遍存在误解,以及标准库大多未使用动态异常等因素,动态异常规范在 C++11 中被弃用,并在 C++17 和 C++20 中从语言中移除。更多背景信息,请参阅此论文。

浙公网安备 33010602011771号