(感谢读者 Koe 提供初稿) 观察一个普通的函数声明,我们无法直接判断该函数是否会抛出异常:
int doSomething(); // 该函数会抛异常吗?
在上述示例中,doSomething()
是否会抛异常并不清楚,而答案在某些场景下至关重要。在《异常的隐患与弊端》中我们提到:若在栈回卷期间析构函数抛出异常,程序将立即终止。如果 doSomething()
可能抛异常,则在析构函数(或其他不希望出现异常的地方)调用它就存在风险。虽然可以让析构函数捕获并处理 doSomething()
抛出的异常,但这需要刻意为之,并确保覆盖所有可能的异常类型。
注释固然可以说明函数是否抛异常以及抛出何种异常,但注释可能过时,且编译器不会强制检查。
异常说明(exception specifications)原本是一种语言机制,用于在函数签名中声明该函数可能抛出的异常类型。如今大多数异常说明已被弃用或移除,唯有一项有用的说明符作为替代保留下来,本节将重点介绍。
noexcept 说明符
在 C++ 中,所有函数被划分为“不抛异常”和“可能抛异常”两类。
- 不抛异常函数承诺不会向调用者抛出任何可见异常。
- 可能抛异常函数则可能向调用者抛出异常。
要将函数声明为不抛异常,可在函数声明中使用 noexcept
说明符,放在形参列表右侧:
void doSomething() noexcept; // 声明为不抛异常
注意:noexcept
并不阻止函数内部抛出异常或调用可能抛异常的函数;只要该函数内部捕获并处理所有异常,使异常不离开本函数,即符合约定。
若未捕获的异常将离开 noexcept
函数,则调用 std::terminate
(即使调用链上层存在能处理该异常的处理器)。如果 std::terminate
在 noexcept
函数内被触发,栈可能回卷也可能不回卷(取决于实现及优化),这意味着对象可能无法正常析构。
关键洞察noexcept
所作“不向调用者抛异常”的承诺是一种契约,而非由编译器强制。若 noexcept
函数因异常处理缺陷而破坏契约,程序将被终止!因此,最佳做法是:让 noexcept
函数完全不接触异常,或根本不调用可能抛异常的函数。
与“仅靠返回值不同不能重载”类似,仅靠异常说明不同也不能重载。
演示 noexcept 与异常的交互行为
以下程序演示各种情况下 noexcept
函数与异常的行为(感谢读者 yellowEmu 提供示例初稿):
// 感谢读者 yellowEmu 提供示例初稿
#include <iostream>
class Doomed
{
public:
~Doomed()
{
std::cout << "Doomed destructed\n";
}
};
void thrower()
{
std::cout << "Throwing exception\n";
throw 1;
}
void pt() // potentially throwing
{
std::cout << "pt (potentially throwing) called\n";
Doomed doomed{}; // 若栈回卷,此对象会被销毁
thrower();
std::cout << "This never prints\n";
}
void nt() noexcept
{
std::cout << "nt (noexcept) called\n";
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; // 每次插入后立即刷新
std::cout << std::boolalpha; // 布尔值输出为 true/false
tester(1);
std::cout << "Test successful\n\n";
tester(2);
std::cout << "Test successful\n";
return 0;
}
在作者机器上输出:
tester (noexcept) case 1 called
pt (potentially throwing) called
Throwing exception
Doomed destructed
tester caught exception
Test successful
tester (noexcept) case 2 called
nt (noexcept) called
Throwing exception
terminate called after throwing an instance of 'int'
随后程序中止。
详细分析:tester 为 noexcept,承诺不向 main 抛异常。
- 第一例:tester(1) 调用可能抛异常的 pt,pt 调用 thrower 抛异常。异常首先被 tester 捕获,栈回卷(局部变量 doomed 被销毁),异常在 tester 内处理。由于异常未离开 tester,因此不违反 noexcept,控制返回 main。
- 第二例:tester(2) 调用 noexcept 函数 nt,nt 又调用 thrower 抛异常。异常需离开 nt 才能被 tester 捕获,这违反了 nt 的 noexcept 约定,于是触发
std::terminate
,程序立即中止。作者机器上栈未回卷(doomed 未被销毁)。
带布尔参数的 noexcept
noexcept 可接受一个可选布尔形参:
noexcept(true)
等价于noexcept
,表示不抛异常;noexcept(false)
表示可能抛异常。
这些形参通常用于模板,使模板函数根据参数化值动态决定为不抛或可能抛异常。
哪些函数默认不抛异常 / 可能抛异常
默认不抛异常:
- 析构函数
默认不抛异常(隐式声明或显式缺省实现):
- 默认、复制、移动构造函数
- 复制、移动赋值运算符
- C++20 起比较运算符
若上述任何函数(显式或隐式)调用可能抛异常的函数,则它们将被视为可能抛异常。例如,若类的某个数据成员具有可能抛异常的构造函数,则该类的相应构造/赋值函数亦被视为可能抛异常。
默认可能抛异常(若非隐式声明或显式缺省):
- 普通函数
- 用户自定义构造函数
- 用户自定义运算符
noexcept 运算符
noexcept 亦可用作运算符。它接受一个表达式参数,并在编译期返回布尔值:若编译器认为该表达式可能抛异常则返回 false,否则返回 true。noexcept 运算符仅在编译期检查,不会实际计算表达式。
void foo() { throw -1; }
void boo() {};
void goo() noexcept {};
struct S {};
constexpr bool b1{ noexcept(5 + 3) }; // true;整数运算不抛异常
constexpr bool b2{ noexcept(foo()) }; // false;foo() 抛异常
constexpr bool b3{ noexcept(boo()) }; // false;boo() 隐式 noexcept(false)
constexpr bool b4{ noexcept(goo()) }; // true;goo() 显式 noexcept(true)
constexpr bool b5{ noexcept(S{}) }; // true;S 的默认构造默认 noexcept
noexcept 运算符可用于根据表达式是否可能抛异常来条件执行代码,以满足某些异常安全保证,下一节将讨论此主题。
异常安全保证
异常安全保证是函数或类在异常发生时对自身行为的契约性约束,分为四级:
- 无保证:异常抛出后无任何承诺,对象可能处于不可用状态。
- 基本保证:异常抛出后无内存泄漏,对象仍可用,但程序状态可能改变。
- 强保证:异常抛出后无内存泄漏,且程序状态保持不变。函数要么完全成功,失败时无任何副作用;也可通过回滚使程序回到失败前状态。
- 不抛/不失效保证:函数总是成功(不失效),或失败时不向调用者抛出异常(不抛)。内部可抛异常,只要不暴露。noexcept 说明符即对应此级别。
进一步说明不抛/不失效保证:
- 不抛保证:若函数失败,不会抛异常,而是返回错误码或忽略问题。栈回卷期间(已存在异常)要求所有析构函数满足不抛保证。
- 不失效保证:函数总能成功完成,故无需抛异常。不失效是比不抛稍强的保证。典型例子包括移动构造、移动赋值、swap、容器 clear/erase/reset、std::unique_ptr 操作等。
何时使用 noexcept
仅因代码未显式抛异常并不意味着应到处添加 noexcept。默认多数函数为可能抛异常;若函数调用了可能抛异常的函数,则自身亦可能抛异常。
合理使用 noexcept 的理由:
- 不抛函数可安全地在析构函数等异常不安全场景被调用。
- noexcept 函数允许编译器做额外优化,无需保持可回卷栈状态,生成更快代码。
- 标准库容器(如 std::vector)利用 noexcept 运算符决定是否使用移动语义(更快)或复制语义(更慢)。《std::move_if_noexcept》将讨论此优化。
标准库策略:仅对“必须不抛/不失效”的函数使用 noexcept;对实现上实际不抛但理论上可能抛的函数通常不标记 noexcept。
建议始终为以下函数添加 noexcept:
- 移动构造函数
- 移动赋值运算符
- swap 函数
考虑为以下函数添加 noexcept:
- 想显式声明不抛/不失效保证的函数(可被析构函数或其他 noexcept 函数安全调用)。
- 不抛的复制构造和复制赋值运算符(以获取优化收益)。
- 析构函数:只要所有成员析构均为 noexcept,则析构函数隐式 noexcept。
最佳实践
- 始终将移动构造、移动赋值、swap 设为 noexcept。
- 尽可能将复制构造、复制赋值设为 noexcept。
- 在其他需要显式不抛/不失效保证的函数上使用 noexcept。
- 若不确定某函数是否应保证不抛/不失效,宁可保守起见,不添加 noexcept。
- 移除已有的 noexcept 会破坏接口承诺,可能破坏现有代码;
- 事后给原本非 noexcept 的函数添加 noexcept 被视为安全。
动态异常说明(可选阅读)
在 C++11 之前直至 C++17,曾使用动态异常说明(dynamic exception specifications)。语法使用 throw
关键字列出函数可能直接或间接抛出的异常类型:
int doSomething() throw(); // 不抛异常
int doSomething() throw(std::out_of_range, int*); // 可能抛 std::out_of_range 或 int*
int doSomething() throw(...); // 可能抛任何异常
由于实现不完整、与模板不兼容、常见误解及标准库基本未采用等原因,动态异常说明在 C++11 中被弃用,并在 C++17/C++20 中移除。详见相关论文。