C++ noexcept

在 C++ 中,noexcept 关键字用来声明一个函数 “不会向外部抛出异常”。从英文拼写上就很容易记住,noexcept 是 “no exception(不抛异常)” 的缩写。

在常规的异常处理中,函数遇到无法继续的错误时,可以使用 throw 把异常抛给外层调用者。而加上 noexcept 则是作出一份反向承诺:不会有任何异常跨出这个函数的边界。哪怕函数内部调用了可能抛异常的代码,也必须在函数内部自己捕获并处理掉。

C++ noexcept 基本用法

在声明或定义函数时,我们只需要把 noexcept 关键字放在参数列表的小括号后面,就表示该函数承诺不向外部抛出任何异常。

语法:

返回类型 函数名(参数列表) noexcept {
    // 函数体
}

说明:

根据函数声明末尾有没有加 noexcept,C++ 中的函数一般分为以下 2 类:

  • 未加 noexcept 的函数:默认属于 “可能抛出异常” 的函数。哪怕函数体里目前一行 throw 都没有写,语法上也允许它以后向外抛出异常。
  • 加上 noexcept 的函数:明确承诺不会让任何异常从该函数传播出去,调用者和编译器都可以按 “不抛异常” 来对待它。

需要注意的是,在多文件编程中,如果把函数声明写在 .h 头文件中、把函数定义写在 .cpp 源文件中,两处都必须同步加上 noexcept。如果只在其中一处添加,编译时就会直接报错。

示例 1:定义 noexcept 函数

#include <iostream>

// 交换两个整数,承诺不抛异常
void swapValue(int& a, int& b) noexcept {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 10;
    int y = 20;

    swapValue(x, y);
    std::cout << "x = " << x << ", y = " << y << '\n';
    return 0;
}

运行结果如下。

x = 20, y = 10

分析:

在这个例子中,swapValue() 只做简单的整数赋值交换,本身不可能产生异常。我们在参数列表后面加上 noexcept,就明确向外界表明调用该函数不会抛出任何异常。

另外,加上 noexcept 并不禁止我们在函数内部使用 try...catch。只要函数把内部发生的异常就地捕获并处理掉,没有让异常跨出当前函数的边界,同样符合 noexcept 的规定。下面我们再来看一个例子。

示例 2:在函数内部捕获并处理异常

#include <exception>
#include <iostream>
#include <stdexcept>

// 模拟可能失败的底层写入
void writeDisk() {
    throw std::runtime_error("磁盘空间不足");
}

// 承诺不向外部抛出异常
void saveLog() noexcept {
    try {
        writeDisk();
    } catch (const std::exception& e) {
        std::cout << "日志内部处理异常:" << e.what() << '\n';
    }
}

int main() {
    saveLog();
    std::cout << "main 函数继续正常执行\n";
    return 0;
}

运行结果如下。

日志内部处理异常:磁盘空间不足
main 函数继续正常执行

分析:

尽管 saveLog() 内部调用的 writeDisk() 抛出了 std::runtime_error 异常,但 saveLog() 自己通过 try...catch 把这个异常接住并处理掉了。从外层 main() 的角度来看,没有任何异常传出 saveLog() 之外,因此程序继续正常往下运行。

noexcept 函数抛出异常会怎样?

如果一个函数声明了 noexcept,结果内部偏偏抛出了异常,而且又没有在函数内部自己 catch 住,会发生什么呢?

很多初学的小伙伴可能会想:“函数里面没接住,我们在外面的 main() 里套一层 try...catch 不就能接住了吗?”

实际上这样是接不住的。一旦异常跨过 noexcept 函数的边界向外传播,C++ 不会给外层 catch 任何接手的机会,而是直接调用标准库的 std::terminate() 函数终止整个程序。

示例 3:noexcept 函数向外抛异常

#include <exception>
#include <iostream>
#include <stdexcept>

// 承诺不抛异常但内部未捕获
void runTask() noexcept {
    throw std::runtime_error("任务执行失败");
}

int main() {
    try {
        runTask();
    } catch (const std::exception& e) {
        std::cout << "main 捕获到异常:" << e.what() << '\n';
    }
    return 0;
}

运行结果如下。

(报错)terminate called after throwing an instance of 'std::runtime_error'
  what():  任务执行失败

分析:

从运行结果可以看出,尽管外层 main() 里写了 catch (const std::exception& e),程序依然没有进入这个分支。它在终端打印出 terminate called after throwing...,意思是抛出异常后直接触发了程序强制终止。

为什么外层写好了 catch 也接不住呢?原因很简单:编译器看到 runTask() 带有 noexcept 标记后,就不会再为它的调用点准备异常传播路线了。只要异常跨出 runTask() 的边界一步,程序就会立即终止。

注意 平时写代码时,我们只有在确定函数不会向外抛出异常时才加上 noexcept。如果对可能抛异常的普通业务函数盲目添加 noexcept,反而会把原本能在外层 catch 补救的错误变成程序直接闪退。

noexcept 条件说明符与运算符(了解)

除了直接写在函数后面的无参形式外,noexcept 还有两种带括号的进阶写法:一种是 noexcept(bool) 条件说明符,另一种是 noexcept(expr) 运算符。在初学阶段,小伙伴们对这两种写法简单了解一下、遇到时能看懂即可。

1. noexcept(bool) 条件说明符

在函数声明末尾,noexcept 后面的小括号里可以接收一个编译期布尔常量。

语法:

返回类型 函数名(参数列表) noexcept(布尔常量);

说明:

我们平时直接写的 noexcept,本质上就是 noexcept(true) 的简写。如果写成 noexcept(false),则表示该函数 “可能会抛出异常”,与不写 noexcept 的默认效果完全相同。

void task1() noexcept;           // 承诺不抛异常
void task2() noexcept(true);     // 承诺不抛异常
void task3() noexcept(false);    // 允许抛出异常

这种带布尔条件的写法主要用于编写泛型模板:当模板函数不确定传入的类型操作会不会抛异常时,可以把判断条件写进括号里,由编译器根据具体类型自动决定。

2. noexcept 运算符

当 noexcept(expr) 出现在函数体内部的普通表达式位置时,它就变成了一个编译期运算符,用法很像前面学过的 sizeof 运算符。

语法:

noexcept(表达式)

说明:

noexcept 运算符的作用是在编译阶段检查括号里的表达式是否承诺了不抛异常:如果承诺了不抛异常则返回 true;如果可能抛异常则返回 false。

下面我们来看一个例子。为了方便观察结果,代码中配合使用了 std::boolalpha,让 std::cout 直接把布尔值输出为 true 或 false。

示例 4:使用 noexcept 运算符

#include <iostream>

void safeFn() noexcept {
    std::cout << "执行安全任务\n";
}

void workFn() {
    std::cout << "执行普通任务\n";
}

int main() {
    std::cout << std::boolalpha;
    std::cout << "safeFn():" << noexcept(safeFn()) << '\n';
    std::cout << "workFn():" << noexcept(workFn()) << '\n';
    return 0;
}

运行结果如下。

safeFn():true
workFn():false

分析:

在这个例子中,safeFn() 声明了 noexcept,因此 noexcept(safeFn()) 在编译期返回 true。而 workFn() 没有声明 noexcept,所以 noexcept(workFn()) 返回 false。

在使用 noexcept 运算符时,大家还要留意以下 2 个细节:

  • 括号内的函数不会真正执行:运行结果中并没有打印出 "执行安全任务" 和 "执行普通任务"。因为 noexcept 运算符与 sizeof 一样,只是在编译期检查函数声明,不会在运行时真正调用函数。
  • 函数名后面的 () 不能漏写:我们要检查的是 “调用该函数” 会不会抛异常,因此括号内必须写成 noexcept(workFn())。如果漏掉小括号写成 noexcept(workFn),就变成了只取函数地址而不调用它,结果永远是 true。

C++ noexcept 的应用场景

在实际开发中,最需要加上 noexcept 的典型场景,就是类的移动构造与移动赋值。

我们可以结合 vector 容器的自动扩容来理解。当容器容量不够时,需要重新申请一块更大的内存,再把旧元素一个个搬过去。如果采用 “移动” 方式搬到一半突然抛出异常,原容器里的一部分元素已经被掏空,旧数据就遭到了破坏。而如果采用 “拷贝” 方式,哪怕半路出错,原容器里的旧元素依然完好无损。

因此,std::vector 在扩容搬运旧元素前,会先检查元素类的移动构造函数有没有标 noexcept。只有标了 noexcept 保证 “搬运中途不会抛异常”,容器才敢放心调用高效的移动构造函数。否则为了数据安全,它就会退回使用较慢的拷贝构造函数。

示例 5:vector 扩容与 noexcept 移动构造

#include <iostream>
#include <vector>

class Item {
public:
    Item() {}

    // 拷贝构造函数
    Item(const Item&) {
        std::cout << "调用拷贝构造\n";
    }

    // 移动构造函数:加上 noexcept
    Item(Item&&) noexcept {
        std::cout << "调用移动构造\n";
    }
};

int main() {
    std::vector<Item> list(1);    // 初始包含 1 个元素
    list.reserve(2);              // 预留容量 2,触发扩容搬运
    return 0;
}

运行结果如下。

调用移动构造

分析:

在这个例子中,我们先创建了包含 1 个元素的容器 list,随后调用 list.reserve(2) 预留更大容量,主动触发容器重新分配内存并搬运旧元素。由于 Item 类的移动构造函数加上了 noexcept,std::vector 放心调用了移动构造函数。

如果你想验证两者的区别,可以把 Item(Item&&) 后面的 noexcept 删掉再运行试试,就会发现输出结果变成了 "调用拷贝构造"。

总结起来,在实际开发中,以下 3 类函数最适合加上 noexcept:

  • 移动构造函数与移动赋值运算符:移动操作通常只是转移内部指针,不涉及重新申请内存,本身不会抛异常。加上 noexcept 可以让 std::vector 等标准库容器放心启用移动优化。
  • 类的析构函数:在 C++11 及之后的标准中,即使不手动写,类的析构函数默认也自带 noexcept。因为析构函数经常在异常 “栈展开” 清理局部对象时执行,不能再往外抛出新异常。
  • 逻辑简单、不会失败的小函数:比如交换两个数据的 swap() 函数、单纯返回成员变量值的读取函数等,加上 noexcept 可以让函数的行为边界更加明确。

至于大多数普通的业务函数,如果拿不准它内部调用的其他函数将来会不会抛出异常,直接保持默认不写 noexcept 就可以了。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号