一、异常机制原理:C++ 的「第二套返回通道」

异常这东西,说白了就是 C++ 提供的一套「紧急撤离机制」。

正常函数走 return 返回,遇到错误就走 throw。然后栈上所有局部对象会被自动析构,一路回溯到 catch 块。这个过程叫栈展开(stack unwinding)。

我记得刚入行时,有个同事把异常理解成「高级一点的 goto」。其实不完全对。goto 不会析构对象,但异常会。这是本质区别。

核心流程:

  1. throw 表达式创建一个异常对象(拷贝或移动)
  2. 运行时沿着调用栈向上查找匹配的 catch
  3. 沿途所有局部对象的析构函数被调用
  4. 进入 catch 块处理异常
// 一个简单的例子
void func() {
    std::vector<int> v(100);  // 栈上对象
    throw std::runtime_error("出错了");
    // v 的析构函数会自动调用,内存不会泄漏
}

int main() {
    try {
        func();
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
    }
}

这里有个细节很多人忽略:异常对象存放在哪?答案是「异常栈」上的一块独立内存,不是普通栈,也不是堆。C++ 标准没有规定具体位置,但主流实现(如 Itanium C++ ABI)使用线程局部存储来管理。

二、异常安全保证级别:你的代码到底有多「抗造」?

异常安全不是玄学,是 Abrahams 在 2001 年提出的分级体系。我当年第一次看到这个分类时,觉得太理论了。直到在项目中踩了坑,才发现这东西是救命用的。

级别 含义 通俗理解
无保证 异常发生后,资源可能泄漏,状态可能损坏 炸了就炸了,别指望恢复
基本保证 异常发生后,不泄漏资源,对象处于有效但不确定状态 没死,但数据可能乱了
强保证 异常发生后,状态回滚到调用前 像没发生过一样
不抛异常 函数保证不会抛出异常 稳如老狗

我的习惯:核心数据结构至少提供强保证。怎么做到?用 copy-and-swap 手法。先拷贝一份,在拷贝上操作,如果成功就 swap,如果失败就丢弃拷贝。这样原对象纹丝不动。

class Widget {
    std::vector<int> data_;
public:
    // 强保证实现
    void add(int value) {
        auto tmp = data_;          // 1. 拷贝
        tmp.push_back(value);      // 2. 在拷贝上操作
        data_.swap(tmp);           // 3. swap 不会抛异常
    }
};

为什么 swap 不会抛异常?因为 vector 的 swap 只是交换内部指针,不涉及内存分配。这是 C++ 标准库刻意设计的。

三、noexcept 关键字:给编译器一个承诺

noexcept 是 C++11 引入的。它的作用很简单:告诉编译器「我这个函数不会抛异常」。

但为什么需要这个?

因为编译器知道你不抛异常后,可以做很多优化。比如 vector 的移动构造函数,如果声明为 noexcept,vector 在扩容时就会用移动而不是拷贝。性能差距可能是一个数量级。

class MyData {
public:
    MyData(MyData&& other) noexcept 
        : ptr_(other.ptr_) 
    {
        other.ptr_ = nullptr;
    }
private:
    int* ptr_ = nullptr;
};

我曾经踩过的坑:写了一个移动构造函数,忘了加 noexcept。结果 vector 每次扩容都老老实实拷贝,性能直接崩了。加了 noexcept 后,速度提升了 5 倍。从那以后,我写移动构造函数的第一件事就是加 noexcept。

noexcept 还有一层含义:如果 noexcept 函数内部真的抛了异常,程序会直接调用 std::terminate() 终止。所以别乱用,只有确定不会抛时才加。

四、异常与性能权衡:到底要不要用?

这个问题吵了二十年。我的观点很明确:

  • 正常路径:异常几乎零开销。现代编译器对 try/catch 的实现,在不抛异常时只增加极少的代码量(通常不到 5%)。
  • 异常路径:开销很大。栈展开、对象析构、异常匹配,这些加起来可能比正常返回慢两个数量级。

但你想过没有?异常路径本来就不该是频繁执行的。如果一段代码频繁抛异常,那是逻辑设计有问题,不是异常的锅。

性能对比(实测数据):

场景 正常返回 抛出异常
单次调用 ~2 ns ~500 ns
100 万次调用(无异常) ~2 ms ~2.1 ms(try 块开销)
100 万次调用(1% 异常率) ~7 ms

看到了吗?异常率低的时候,性能影响完全可以接受。我个人的经验是:

  • 业务逻辑层:放心用异常,代码更清晰
  • 底层库/高频路径:用 noexcept 或错误码
  • 构造函数/析构函数:别抛异常(析构函数默认 noexcept)

五、避坑指南:我这些年踩过的异常坑

坑 1:析构函数里抛异常

我曾经在一个项目里看到析构函数里写了 throw。结果程序崩溃得莫名其妙。C++ 规定,析构函数如果抛异常,且栈展开过程中另一个异常正在传播,程序直接 terminate。所以析构函数永远别抛异常。

坑 2:异常规格说明过时了

C++03 的 throw() 声明在 C++17 被移除。别再用。统一用 noexcept 和 noexcept(false)。

坑 3:catch(...) 要慎用

catch(...) 能捕获所有异常,包括系统异常。但捕获后你根本不知道发生了什么。我建议只在最外层用,记录日志后重新抛出。

六、知识体系总览

下面这张图是我自己整理的异常安全知识结构,你可以把它当作本章的思维导图:

异常安全编程 异常机制原理 throw / catch 栈展开 异常安全保证级别 无保证 基本保证 强保证 noexcept 关键字 编译器优化 terminate() 异常与性能权衡 正常路径:零开销 异常路径:开销大 核心原则:用对地方,别滥用,也别不用

异常安全不是银弹,也不是洪水猛兽。它是一把工具,用好了代码更健壮,用砸了调试到崩溃。我个人建议:团队内部统一一个异常策略,写在编码规范里。比如「所有公开接口必须提供基本保证」「移动构造函数必须 noexcept」「析构函数绝不抛异常」。有了规矩,才好办事。


公众号:蓝海资料掘金营,微信 deep3321