条款14:如果函数不抛出异常,请使用 noexcept
1. 引言
在 C++ 中,异常规范(exception specification)经历了从 C++98 的throw()到 C++11 的noexcept的演变。noexcept不仅是一种声明函数不抛出异常的方式,更是现代 C++ 中优化代码、提升性能的重要工具。本条款将深入探讨noexcept的语义、使用场景、对编译器优化的影响,以及何时应该(或不应该)使用它。
2. noexcept 的基本语法与语义
noexcept有两种基本形式:无条件版本和有条件版本。
// 无条件 noexcept:保证函数不会抛出任何异常 void foo() noexcept; // C++11 起推荐写法 void bar() noexcept(true); // 等价于 noexcept // 有条件 noexcept:根据编译期条件决定是否抛出异常 template<typename T> void swap(T& a, T& b) noexcept(noexcept(a.swap(b)));与 C++98 的throw()不同,noexcept是函数类型的一部分,并且违反noexcept承诺会导致程序直接调用std::terminate,而不是像throw()那样可能引发std::unexpected。
3. 为什么使用 noexcept?
3.1 性能优化
编译器在生成代码时,如果知道函数不会抛出异常,就可以进行更多优化:
- 栈展开(stack unwinding)代码的省略:编译器无需为可能抛出的异常生成栈展开信息,减少代码体积。
- 移动语义的启用:标准库容器(如
std::vector)在重新分配内存时,如果元素的移动构造函数被标记为noexcept,则会优先使用移动而非拷贝,从而提升性能。
class MyType { public: // 移动构造函数标记为 noexcept,使 std::vector 重分配时能安全移动 MyType(MyType&& other) noexcept; };3.2 接口契约的明确性
noexcept是函数接口的一部分,它向调用者明确承诺:“调用此函数不会因异常而失败”。这使得代码更容易推理,也便于编写异常安全的代码。
3.3 标准库的要求与利用
许多标准库算法和容器对操作的异常安全性有要求。例如,std::swap、std::move_if_noexcept等设施会利用noexcept信息来选择更高效且安全的行为。
4. 何时应该使用 noexcept?
- 析构函数:析构函数默认就是
noexcept的,除非显式指定为可能抛出异常(noexcept(false))。通常不应让析构函数抛出异常。 - 移动操作:移动构造函数和移动赋值运算符应尽可能标记为
noexcept,以允许标准库容器在重分配时使用移动而非拷贝。 - 交换函数:
swap函数通常应标记为noexcept,以保证异常安全且高效。 - 简单访问函数:如
getter、size()、empty()等不执行复杂操作、仅返回内部状态的函数。 - 承诺不抛出的函数:如果你能 100% 确定函数及其调用的所有子函数都不会抛出异常。
5. 何时不应该使用 noexcept?
- 函数可能抛出异常:如果函数内部调用了可能抛出异常的操作(如动态内存分配、文件 I/O、网络请求),且没有妥善处理,则不应标记
noexcept。 - 虚函数:如果基类虚函数未声明
noexcept,则派生类重写版本也不能添加noexcept(除非基类版本本身是noexcept)。 - 无法确定的泛型代码:在模板中,除非使用
noexcept(noexcept(...))进行条件判断,否则不应轻易添加无条件noexcept。
6. noexcept 与移动语义的紧密关系
这是noexcept最重要的应用场景之一。考虑std::vector::push_back:当容量不足需要重新分配时,它需要将旧元素移动到新内存。如果元素的移动操作可能抛出异常,那么移动中途失败会导致旧状态被破坏,无法提供强异常保证。因此,std::vector会检查移动构造函数是否标记为noexcept:
- 如果是
noexcept,则使用移动,效率高且安全。 - 如果不是
noexcept,则使用拷贝,以保证强异常安全(即使拷贝抛出异常,原容器保持不变)。
// 示例:noexcept 如何影响 vector 的行为 std::vector<MyType> v; v.reserve(10); // ... 添加元素至容量满 v.push_back(MyType{}); // 此处可能触发重分配 // 如果 MyType 的移动构造函数是 noexcept,则移动;否则拷贝。7. 条件 noexcept
对于模板函数或依赖于模板参数的函数,可以使用条件noexcept来声明异常规范。
template<typename T> void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) { a.swap(b); } // 或者使用 std::is_nothrow_swappable template<typename T> void my_swap(T& a, T& b) noexcept(std::is_nothrow_swappable<T>::value) { using std::swap; swap(a, b); }条件noexcept在编写泛型库时尤其重要,它允许你根据类型特性来提供最优的异常保证。
8. 检测 noexcept 与 noexcept 运算符
C++11 引入了noexcept运算符,它是一个编译期布尔常量表达式,用于检查表达式是否可能抛出异常。
bool b1 = noexcept(foo()); // 检查 foo() 是否声明为 noexcept bool b2 = noexcept(T().swap(T())); // 检查 T 的 swap 操作是否 noexcept // 常用于条件 noexcept 声明中 template<typename T> void func(T& t) noexcept(noexcept(t.some_operation())) { t.some_operation(); }此外,std::is_nothrow_constructible、std::is_nothrow_move_constructible等类型特性也可用于编译期检测。
9. 实践建议与总结
- 默认不添加:除非你能确信函数不会抛出异常,否则不要轻易添加
noexcept。错误的noexcept比没有更危险。 - 移动操作优先:为移动构造函数和移动赋值运算符添加
noexcept,这是提升标准容器性能的关键。 - 析构函数保持默认:不要为析构函数指定
noexcept(false)。 - 关注标准库兼容性:如果你编写的类型可能被用于标准容器,请确保其移动操作是
noexcept的。 - 使用条件 noexcept 编写泛型代码:在模板中,使用
noexcept(noexcept(...))来传播异常规范。 - noexcept 是接口的一部分:一旦声明,就成为 API 契约,后续移除
noexcept可能破坏用户代码。
遵循“如果函数不抛出异常,请使用noexcept”这一条款,不仅能提升代码性能,还能使接口意图更加清晰,是现代 C++ 高效编程的重要实践。
