条款14:只要函数不会发射异常,就为其加上 noexcept 声明

ZaynPei Lv6

正如标题所指出的, noexcept 是一个关键的函数接口规范,它能带来显著的编译器优化,并对移动语义的实现至关重要。

在 C++11 之前,C++98 提供了异常规格(如 throw(SomeException) 或 throw()),但这套机制要求开发者列出函数可能抛出的所有异常类型,这在实践中难以维护,且编译器通常不会严格检查其一致性,导致该特性最终被普遍弃用。实际上, 开发者真正关心的信息是二元的:一个函数是否可能发射异常。noexcept 声明就是 C++11 中用于保证函数不会发射异常的工具

核心动机:编译器优化

为函数添加 noexcept 声明的最直接动机是,它允许编译器生成更优的目标代码。这源于 noexcept 和 C++98 的 throw() 在违反承诺时的不同处理方式:

  • int f(int x) throw(); (C++98 风格):如果在运行时,一个异常企图逸出 f 的作用域,程序会中止,但在中止前,调用栈必须被“展开”(unwound)至 f 的调用方。

  • int f(int x) noexcept; (C++11 风格):如果一个异常企图逸出 f 的作用域,程序也会中止,但标准规定栈只是可能会被开解。

这个“必须开解”与“可能开解”的区别对优化器至关重要。对于一个 noexcept 函数,编译器在生成代码时,无需在异常逸出函数时将执行期栈保持在可开解状态,也无需保证函数内的所有对象以其构造顺序的逆序完成析构。这种灵活性使得优化器可以生成更精简、更高效的代码。而没有 noexcept 声明的函数则无法享受这种优化。

关键用例:noexcept 与移动语义

noexcept 最重要的实际应用在于它与移动语义的交互。许多 C++11 的功能(特别是标准库容器)为了性能会优先选用移动操作而非复制操作,但前提是它们必须保证不破坏 C++98 代码所依赖的异常安全承诺。

以 std::vector::push_back 为例:当向 std::vector 添加一个新元素,而此时其 size() 等于 capacity() 时,vector 必须分配一块新的、更大的内存,并将所有旧元素转移到新内存中。

  • C++98 (复制):在 C++98 中,这个转移是通过复制元素完成的。这提供了强异常安全保证:如果在复制第 n+1 个元素时抛出异常(例如内存不足),vector 可以保持在原始状态(因为旧内存中的元素都还在, 旧内存中的元素直至所有的元素被成功复制入新内存以后,才会被执行析构),保证了数据的不变性。
  • C++11 (移动):在 C++11 中,使用移动操作来转移元素会高效得多。但是,移动操作通常会“掏空”源对象。如果在移动了 n 个元素后,在移动第 n+1 个元素时抛出异常,此时操作无法回滚:旧内存中的前 n 个元素已被移走(处于无效状态),而新内存也没有完全构建好。这破坏了强异常安全保证。

为了解决这个冲突,std::vector::push_back 等函数在 C++11 中遵循一个规则:它们只会在元素的移动构造函数被显式声明为 noexcept 时,才会使用移动操作。如果移动构造函数没有 noexcept 声明(意味着它可能会抛出异常),vector 将退回到使用(更慢的)复制操作,以确保强异常安全保证不被破坏。

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 函数

前面的规则似乎已经很完备, 然而, 对于模板(template)或管理其他类型成员的复合类型(composite types),我们编写函数时(如 swap),并不知道它所操作的底层类型是否会抛出异常。这就是 noexcept(expression) 这种用法(条件 noexcept)的用武之地。这里的关键是区分:

  • noexcept 关键字(修饰符):放在函数声明的末尾,告诉编译器这个函数是否承诺不抛出异常。

  • noexcept(expression) 运算符:一个在编译期求值的运算符。如果 C++ 编译器确定 expression 表达式(它并不会真的执行这个表达式,只是分析它)保证不会抛出异常(即表达式本身是 noexcept 的),那么这个运算符返回 true;否则返回 false。

下面是针对数组和pair的条件noexcept示例:

1
2
3
4
5
6
7
8
9
10
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)));
// ...
};
第一个模板代码是一个针对 C 风格数组(具有相同类型 T 和相同大小 N)的 swap 函数模板的特化版本。我们知道, 要交换两个数组,其实现(例如 std::swap 对数组的实现)通常是遍历数组,并对数组中的每一个对应元素执行 swap 操作。

而这个数组 swap 函数是否会抛出异常?这完全取决于它在循环内部调用的 swap(a[i], b[i]) 是否会抛出异常。如果交换一个 T 类型的元素(swap(T&, T&))是 noexcept 的,那么交换整个数组(循环执行N次)也自然是 noexcept 的。反之,如果交换 T 可能会抛异常,那么这个数组 swap 函数也必须被视为可能抛异常。

这便是代码noexcept(noexcept(swap(*a, *b)))的意义, 内部的 noexcept(swap(a, b))是一个 noexcept 运算符, 它检查是否调用类型 T 的 swap(即 swap(T&, T&))被声明为 noexcept. 如果是,noexcept 运算符返回 true; 外部的 noexcept(…)是一个 noexcept 修饰符, 它接收内部运算符返回的 bool 值(true 或 false)并决定是否修饰函数。第二个示例同理

通过使用条件 noexcept,这些模板(数组 swap)和复合类型(pair)能够将其自身的异常规范建立在其所依赖的底层类型的异常规范之上。

最后还要区分“异常中立”函数:大多数函数本身不抛出异常,但它们调用的其他函数可能会抛出异常。这种“路过”异常的函数被称为“异常中立” (exception-neutral) 的,它们也不应该被声明为 noexcept。

在 C++11 中,默认规定内存释放函数和所有的析构函数(无论是用户定义的,还是编译器自动生成的)都隐式地具备 noexcept 性质。

On this page
条款14:只要函数不会发射异常,就为其加上 noexcept 声明