C++ 异常与错误处理
C++ 有两套并行的错误处理体系:异常(exception)处理"不该发生但发生了"的意外,错误即值(std::optional / std::expected)处理"完全可能发生"的预期失败。很多人混用它们,结果要么用异常做流程控制、要么对所有失败都返回 expected 把调用方淹没在 if (!result) 里。这篇把两套机制的边界、异常的生命周期、异常安全等级、以及"何时该用哪一种"一次讲清楚。
std::optional 和 std::expected 的接口细节见 C++ optional & expected,这里只讲策略层面的取舍。
异常的生命周期
抛出一个异常时,运行时会沿着调用栈向上查找匹配的 catch,找到前先逐帧栈展开(stack unwinding):每个被跳过的局部对象都会按构造的逆序析构。这正是 RAII 能保证资源释放的根本机制——异常一旦抛出,所有局部对象析构,资源自动回收。
1 | |
栈展开期间的关键规则:
- 局部对象析构是自动的,但你必须保证析构函数本身不会抛异常(见下文
noexcept析构)。 - 构造期间抛异常:已经构造好的成员和基类子对象会被析构,但对象本身从未"完成构造",所以它的析构函数不会被调用。这是"构造函数抛异常"时唯一的坑——必须确保每个成员自己能清理。
- 数组/容器元素构造抛异常:已构造的元素按逆序析构,已分配的内存释放。标准容器都保证这一点。
throw 与 catch 的类型匹配
1 | |
匹配规则比函数重载严格得多——只允许隐式转换到基类引用/指针、const 调整、数组/函数退化,不允许算术转换或自定义转换。所以 catch (const std::exception&) 是兜底,catch 顺序应从派生到基类,先具体后宽泛。
重新抛出与嵌套异常
1 | |
throw;(无操作数)在 catch 块里重新抛出当前异常,保留原始类型;throw e; 则会拷贝切片,丢失派生类型,几乎总是错的。
noexcept:声明"我不会抛"
noexcept 是函数的契约声明:承诺不抛异常。它有两个用途——优化和约束。
1 | |
为什么 noexcept 能提速
标准库的很多操作会先问类型会不会抛,再决定走哪条路径。最典型的就是 std::vector 扩容:扩容要逐个移动/拷贝旧元素,如果元素的移动构造是 noexcept 的,vector 就用移动(快);否则为强异常安全起见退化为拷贝(慢但安全)。
1 | |
这也是为什么"移动构造尽量加 noexcept"是条硬规矩——不只是文档,它直接改变标准库的行为。
noexcept(false) 与条件式 noexcept
1 | |
析构函数默认 noexcept
从 C++11 起,析构函数隐式 noexcept(除非显式 noexcept(false) 或基类/成员析构会抛)。这是有原因的:栈展开期间若析构再抛异常,且当前已有异常在传播,std::terminate 立即被调用。永远不要让析构函数抛异常——这是 C++ 最硬的几条铁律之一。
违反 noexcept 的后果
noexcept 函数里真抛了异常不会"偷偷吞掉",而是直接 std::terminate:
1 | |
所以 noexcept 是一个承诺:你向编译器和库保证不抛,违约代价是程序直接死。别轻易加,加了就确保守住。
异常安全等级
Sutter 提出的四个等级,从弱到强:
| 等级 | 保证 | 典型手段 |
|---|---|---|
| No-throw | 绝不抛 | 析构、swap、移动基本类型 |
| Strong(强) | 失败时状态回滚到调用前,像没发生过 | copy-and-swap、先准备再提交 |
| Basic(基本) | 失败时对象仍处于有效状态、不泄漏,但具体值不确定 | 大部分标准库操作 |
| No guarantee | 失败时啥都可能发生 | 写得糙的代码 |
强异常安全的经典模式:copy-and-swap
1 | |
关键思想:把可能抛异常的操作和"提交"分开。能抛的全做完且成功后,用一个 noexcept 的操作原子地切换状态。若步骤 1 抛了,this 完全没被动过,满足强保证。
先准备再提交
std::vector::push_back 扩容就是这个套路:先分配新内存、在新内存里构造所有元素(这步可能抛),全部成功后再释放旧内存、切换指针。任意一步失败,旧数据原封不动。
异常 vs 错误即值:何时用哪个
这是现代 C++ 最大的设计取舍之一。判断标准是这个失败是"预期之内"还是"意外"。
| 信号 | 用异常 | 用 optional/expected |
|---|---|---|
| 频率 | 罕见(异常路径) | 常见甚至主流 |
| 性能 | 抛时有开销,不抛时近零 | 每次都要 check,但可预测 |
| 强制性 | 调用方可忽略( terminates 或 propagate) | 调用方必须显式处理返回值 |
| 传播 | 自动沿栈展开 | 手动 return 逐层传递(或 ? 等糖) |
| 信息量 | 携带类型化对象 + 调用栈 | 携带错误码 / 错误对象 |
经验法则:
- 配置加载失败、文件不存在、网络超时、JSON 解析错 → 预期失败,用
std::expected。调用方大概率要据此分支,错误是正常逻辑的一部分。 - 内存耗尽、不变式被破坏、不可能的状态 → 意外,用异常(或
assert/ 直接 terminate)。这些不该在正常流程里处理。 - "找不到"算正常结果(如
map::find)→std::optional,连错误信息都不需要。
不要用异常做流程控制
1 | |
抛异常在未抛路径上几乎免费,但一旦进入抛出路径开销大且语义上误导——它声明"出问题了",而不是"这是结果之一"。
expected 串联:monadic 风格
C++23 给 std::expected(和 optional)加了 monadic 接口,让逐层传播不再被 if (!r) return r.error() 淹没:
1 | |
每一步失败会短路到最终返回值,成功则链式继续。这比层层 try/catch 在"预期失败"场景里干净得多。
异常的开销模型
很多人怕异常是因为听说"异常很慢"。其实要分两种:
- 无异常抛出(快路径):现代编译器(Itanium ABI / zero-cost exception)下,正常路径只生成少量"异常表"元数据,运行期几乎零开销——没有分支、没有设置/检查标志位。代价是二进制体积略增。
- 异常抛出(慢路径):抛出时要分配异常对象、走栈展开、查表,比一次函数调用慢一两个数量级。
所以"异常用于异常路径"这个原则还有性能层面的合理性:只要异常真的罕见,整体更快;如果把它当返回值用、频繁抛出,反而比 expected 慢。
-fno-exceptions 可以完全关闭异常(嵌入式、游戏引擎常见),代价是 throw 变 terminate、标准库容器的强异常安全退化。关了异常的项目通常全面改用 expected / abort。
自定义异常类型
从 std::exception 派生,覆盖 what(),便于在 catch (const std::exception&) 兜底时仍能拿到信息:
1 | |
- 优先用标准库已有的
std::runtime_error/std::logic_error/std::system_error(带std::error_code)。 - 按值抛、按引用捕(
catch (const ParseError&)),别按指针抛——指针的 ownership 谁管? - 别抛内置类型(
throw 42;),失去类型信息和what()。
全局兜底
未被任何 catch 捕获的异常会触发 std::terminate,默认打印消息后 std::abort。可以装个自定义 handler 记录崩溃栈:
1 | |
catch (...) 能捕获任意类型但拿不到对象,只适合最后兜底做清理。
一句话总结
异常和 expected 不是二选一的敌人,而是分工:异常管"意外",expected 管"预期失败",optional 管"可能有"。判定标准永远是——这个失败是程序正常运转的一部分,还是出了岔子?想清楚这一条,两套机制就能各司其职,而不是互相打架。




