3.6 异常安全与栈展开


3.6 异常安全与栈展开

本节摘要:throw 拉响的不是简单跳转,而是栈展开——从抛出点到捕获点之间,每一层栈帧的局部对象都要被逐一析构。这既是 RAII 的终极考官,也是异常安全三档保证(基本、强、不抛)的用武之地。本节复盘栈展开全程、noexcept 的双面角色,以及工程界"异常 or 错误码"之争的务实结论。

学习目标

阅读完本节,你应当能够:

  1. 描述从 throw 到 catch 之间栈展开的完整过程;
  2. 说出异常安全三档保证的定义并给函数标注水位;
  3. 解释 noexcept 既是优化器线索又是契约的双面角色;
  4. 在异常与错误码之间做出有依据的项目级选择。

一、栈展开:一场逐层清场的大撤退

#include <iostream> #include <stdexcept> #include <string> struct Guard { explicit Guard(std::string name) : name_(std::move(name)) {} ~Guard() { std::cout << name_ << " 析构:清理现场\n"; } std::string name_; }; void inner() { Guard g("inner 局部对象"); throw std::runtime_error("inner 出事"); } void middle() { Guard g("middle 局部对象"); inner(); } int main() { try { middle(); } catch (const std::exception& e) { std::cout << "捕获:" << e.what() << '\n'; } } // 输出: // inner 局部对象 析构:清理现场 // middle 局部对象 析构:清理现场 // 捕获:inner 出事

throw 之后控制权不再顺序执行:运行时沿调用链向上搜索最近的匹配 catch,沿途按构造的逆序析构每个栈帧里的完整对象——这正是第 2.1 节"析构倒放"纪律在异常场景的执行。输出证明了两件事:RAII 对象在异常路径照样释放;栈展开清场是自动的,不需要你在每个函数里手写清理。

要点罗列:

  • 抛对象、捕引用catch (const std::exception& e) 是标准姿势——按引用捕获避免切片(2.2 节的原理在此重现),const 承诺不改异常对象。
  • 异常体系std::exception 为根,logic_error(程序缺陷,可预防)与 runtime_error(环境故障,如内存不足、文件缺失)两大支,what() 给人读的信息。自定义异常继承 std::runtime_error 一行即可。
  • 栈展开期间再抛异常,程序直接 std::terminate——析构函数因此必须尽量不抛,这是"析构默认 noexcept"的语言规定。
  • 没有 catch 接住同样 terminate。main 外围其实有一层兜底 catch,打印后调用 terminate 结束进程。

catch 的匹配按顺序进行、首次匹配生效,所以派生类 catch 必须写在基类 catch 之前,否则永远轮不到。catch (...) 捕获一切,常用于边界处记录日志后重新抛出。

二、异常安全三档保证

函数面对异常可能处于什么水位,业界共识分三档:

档位 承诺 实现手段 典型例子
基本保证 出异常后无泄漏、对象处于合法(未必原样)状态 RAII 即可达 多数函数的底线
强保证 出异常后状态完全回滚,如同函数没来过 拷贝并交换、先做后换 标准库的 sort 等算法
不抛保证 承诺绝不抛异常 noexcept 标注、只做不抛操作 析构、swap、移动构造

3.2 节 Buffer 的拷贝赋值"先 new 后 delete"就是强保证的实现细节:拷贝失败时旧数据分毫未动。强保证的经典配方是"先在副本上完成全部可能失败的操作,最后用不抛的 swap 一步换装":

Widget& Widget::operator=(const Widget& other) { Widget tmp(other); // 可能抛:此时 this 原封未动 swap(tmp); // 不抛:指针交换 return *this; } // tmp 析构带走旧资源

noexcept 的双面角色要单独强调:它既是给优化器的"这条路径不会抛"的线索(编译器可以少生成处理路径),更是一份硬契约——标了 noexcept 却抛出异常且未被就地处理,terminate 立即执行。3.4 节提过:vector 扩容只信 noexcept 的移动构造。由此得出日常分工:析构函数天然 noexcept;swap 与移动操作应当标 noexcept;会抛的函数诚实地不标。C++17 的 noexcept 还成了类型系统的一部分(参与重载决议),想标想撤都要趁接口稳定的时机。

三、异常 vs 错误码:一个务实结论

异常与错误码之争二十年无定论,但工程边界其实清晰。异常的优势:正常路径零噪音、错误不会假装被处理(错误码的 if (err) 被漏写是常事)、构造函数只能靠异常报错(没有返回值可用)。异常的代价:控制流不显式、二进制体积与(历史上的)运行期开销、与 C 接口不兼容、嵌入式等环境常禁用。

务实的取舍:应用程序与库的公共 API 用异常表达"意外故障";可预期的"查询结果"用返回值(optional、expected、状态码)——文件不存在的查询是结果不是灾难,网络失败是日常不是意外。跨模块边界、跨线程(异常不能跨线程传播,需要 future 的异常传递机制,第 4.3 节)与 C ABI 边界处转成错误码。团队最重要的是统一:一半异常一半错误码的代码库两头缺点全占。

配套的三个工具:

// 构造失败只有异常一条路 FileHandle f(path); // 打不开就抛 失败即对象不存在 // 可预期的缺失用 optional #include <optional> std::optional<int> find_id(const std::string& name); // 没找到返回空 // 自定义异常要有信息量 class ConfigError : public std::runtime_error { public: explicit ConfigError(const std::string& key) : std::runtime_error("配置项缺失:" + key) {} };

⚠️ 常见坑:catch 里吞掉异常不处理也不记录(问题被埋到更远处爆炸);析构函数里抛异常(terminate);用异常做正常控制流(性能与可读性双输);构造函数半途抛异常导致已构造成员正确析构但对象未出生——这正是"构造要么成功要么不存在"哲学的体现,反而是安全的。

本节要点回顾

  • 栈展开 = 逐层倒放析构:RAII 在异常路径同样生效,这是它对堆管理的完胜点。
  • 抛对象捕 const 引用;派生 catch 在前;catch-all 守边界。
  • 三档水位:基本保证靠 RAII 白得,强保证靠"先副本后交换",不抛保证靠 noexcept 固化。
  • noexcept 是契约不是注释:移动与 swap 该标,标了就绝不许抛。
  • 异常与错误码分工:意外故障用异常,可预期结果用 optional 与状态码,跨边界转换,团队统一。

下一节把"行为"也变成对象:Lambda 的捕获列表,本质是编译器替你生成的匿名类的成员变量。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U