Effective C++ 学习笔记 条款29 为“异常安全”而努力是值得的

📅 2026/7/12 18:08:39
Effective C++ 学习笔记 条款29 为“异常安全”而努力是值得的
异常安全有点像怀孕……不过先别急着往下想。在讨论“生育”之前我们还得先把“恋爱”阶段走完。假设我们有一个表示带背景图像的 GUI 菜单类。该类要在多线程环境中使用因此通过一个互斥量来实现并发控制先来看看 PrettyMenu::changeBackground 可能的一种实现从异常安全的角度来看这个函数的表现可谓糟糕透顶。异常安全有两点要求而它一条也没满足。当异常抛出时具备异常安全性的函数应满足1.不泄露资源。上面的代码做不到这一点——因为如果 new Image(imgSrc) 表达式抛出异常对 unlock 的调用就永远不会执行互斥量会被永远持有。2.不允许数据结构遭到破坏。如果 new Image(imgSrc) 抛出异常bgImage 就会指向一个已被删除的对象。此外imageChanges 已经被递增了尽管实际上并未安装新图像不过旧图像确实已被清除所以你也可以说图像确实“改变了”。资源泄露问题很容易解决——条款13阐述了如何用对象管理资源条款14则引入了 Lock 类确保互斥量能及时释放资源管理类如 Lock最大的好处之一就是它们通常能让函数变得更简短。你看unlock 的调用不再需要了吧通常来说代码越少越好——出错的概率更低修改时也更容易理解。解决了资源泄露问题后我们把注意力转向数据结构破坏。这时我们面临一个选择不过在做出选择之前得先了解定义这些选择的术语。异常安全函数提供以下三种保证之一1.基本保证承诺如果抛出异常程序中的一切仍处于有效状态。没有对象或数据结构被破坏所有对象都处于内部一致性状态比如所有类不变式都得到满足。不过程序的确切状态未必可预测。例如我们可以把 changeBackground 写成这样如果抛出异常PrettyMenu 对象可能继续保留旧背景图也可能换成一个默认背景图但客户无法预知究竟是哪一种想知道的话大概需要调用某个成员函数来查询当前背景图。2.强保证strong guarantee承诺如果抛出异常程序状态保持不变。调用这类函数具备原子性——要么成功全部完成要么失败程序状态就跟没调用过一样。使用提供强保证的函数要比仅提供基本保证的来得轻松因为调用完后只可能有两种程序状态要么按预期执行成功要么回到调用前的状态。相比之下如果调用仅提供基本保证的函数时抛出了异常程序可能处于任何一种有效状态。3.不抛出保证nothrow guarantee承诺绝不抛出异常因为这类函数总能完成它们所承诺的工作。对内置类型如 int、指针等的所有操作都是不抛出的即提供不抛出保证。这是异常安全代码的一个关键基石。你或许会认为带有空异常规范empty exception specification的函数就是不抛出的但这并不一定成立。比如下面这个函数这可不是说 doSomething 永远不会抛出异常而是说如果它抛出异常那就是严重错误届时 unexpected 函数该函数会调用terminate终止进程会被调用。实际上doSomething 可能根本提供不了任何异常安全保证。函数的声明包括其异常规范如果有的话既不能告诉你该函数是否正确、可移植或高效也不能告诉你它提供哪种异常安全保证——所有这些特性都由函数的实现决定而非声明。异常安全代码必须提供上述三种保证之一否则就不具备异常安全性。剩下的问题就是你写的每个函数该提供哪种保证除非你手里有个精锐的需求分析师团队明确认定你的应用就是需要泄露资源并运行在混乱的数据结构上——否则不提供任何异常安全保证就不该成为选项。通常来说我们都希望提供尽可能强的保证。从异常安全的角度看不抛出保证当然很理想但要在 C 中完全避开可能抛出异常的函数几乎不可能。凡是涉及动态内存分配的比如所有 STL 容器在无法满足内存请求时通常都会抛出 bad_alloc 异常参见条款49。能做到不抛出保证固然好但对大多数函数来说实际选项是在基本保证和强保证之间做选择。对于 changeBackground 而言提供近乎强保证的做法并不复杂。首先把 PrettyMenu 的 bgImage 数据成员从内置的 Image* 指针改为条款13中提到的某种资源管理型智能指针。老实说仅凭防止资源泄露这一点这个改动就很有价值了而它恰好还能帮我们实现强异常安全保证这也只是进一步印证了条款13的观点用对象如智能指针管理资源是优秀设计的基石。下面的代码中我用了 tr1::shared_ptr因为它在拷贝时的行为更直观通常比 auto_ptr 更可取。其次调整 changeBackground 中语句的顺序——在图像真正更换完成之前不递增 imageChanges。通常来说一个好的原则是不要先改变对象状态来表示“某事已发生”除非那件事确实已经发生了。改进后的代码如下请注意这里不再需要手动删除旧图像因为智能指针内部已经帮我们处理了。而且只有在新图像成功创建后删除操作才会发生。更确切地说tr1::shared_ptr::reset 函数只有在它的参数即 new Image(imgSrc) 的结果成功创建之后才会被调用delete 只在 reset 内部使用所以如果这个函数根本没被进入delete 也就不会执行。同时可以注意到用对象tr1::shared_ptr来管理资源动态分配的 Image又一次缩短了 changeBackground 的长度。如我所说这两处改动已经足以让 changeBackground 接近强异常安全保证了。那还有什么美中不足呢问题出在参数 imgSrc 上。如果 Image 的构造函数抛出异常输入流的读取标记read marker可能已经被移动了而这种移动对于程序其他部分来说是可见的状态变化。在 changeBackground 解决这个问题之前它提供的仍然只是基本异常安全保证。我们先把这个小问题放一放假定 changeBackground 确实提供了强保证我相信你一定能想出解决之道比如把参数类型从 istream 改成包含图像数据的文件名。这里有一条通用的设计策略通常能帮助我们实现强保证很有必要熟悉一下那就是“拷贝并交换copy and swap”。原理非常简单先拷贝一份你想要修改的对象然后在这个副本上执行所有必要的修改。如果修改过程中任何操作抛出了异常原始对象仍保持原样。待所有修改都成功完成后再通过一个不抛异常的操作将修改后的副本与原始对象交换参见条款25。这个策略通常的实现方式是把“真正”对象中所有与对象相关的数据放到一个独立的实现对象里然后让真正对象持有一个指向该实现对象的指针。这通常被称为“pimpl 惯用法”pointer to implementation条款31会详细讲解。对于 PrettyMenu代码大致会像这样在这个例子中我把 PMImpl 设计成 struct 而不是 class是因为 PrettyMenu 数据的封装性已经通过 pImpl 的私有性得到了保证。把 PMImpl 设为 class 效果至少一样好只是稍微没那么方便而且还能让面向对象纯粹主义者无话可说。如果愿意还可以把 PMImpl 嵌套在 PrettyMenu 内部但这类打包问题跟我们这里关注的异常安全代码编写并无直接关系。拷贝并交换策略是实现对象状态“全有或全无”变更的优秀手段但通常来说它并不能保证整个函数具备强异常安全。要理解为什么请看下面这个对 changeBackground 的抽象版本 someFunc——它也用了拷贝并交换但其中包含了另外两个函数 f1 和 f2 的调用显而易见如果 f1 或 f2 达不到强异常安全那么 someFunc 就很难做到强异常安全。举个例子假设 f1 只提供基本保证。要让 someFunc 提供强保证就得在调用 f1 之前先记录整个程序的状态然后捕获 f1 抛出的所有异常最后再把状态恢复回去。即便 f1 和 f2 都是强异常安全的情况也未必好到哪里去。毕竟f1 一旦执行完毕程序状态可能已经发生了各种变化所以如果此时 f2 再抛出异常程序状态就跟调用 someFunc 时不一样了——即使 f2 本身什么也没改变。问题的根源在于副作用side effects。只要函数只操作局部状态比如 someFunc 只影响它被调用对象本身的状态实现强保证还算相对容易。一旦函数对非局部数据产生了副作用难度就大多了。举例来说如果调用 f1 的副作用之一是修改了数据库那 someFunc 就很难做到强异常安全——因为一般来说已经提交的数据库修改是无法撤销的其他数据库客户端可能已经看到了数据库的新状态。这类问题会阻碍你为函数提供强保证哪怕你很想这么做。另一个障碍是效率。拷贝并交换的核心思路是先修改对象数据的副本再通过不抛异常的操作将修改后的副本与原始数据交换。这就意味着需要对每个要修改的对象做一份拷贝——需要消耗你未必能承担、也未必愿意付出的时间和空间。强保证固然非常可取在可行时也确实应该提供但它并非在任何情况下都行得通。行不通的时候你就得退而提供基本保证。实践中你会发现某些函数可以提供强保证但对于另外很多函数来说效率或复杂度的代价会让它变得不可承受。只要你已经在可行范围内尽到了合理努力去提供强保证那么当你最终只提供基本保证时没人有理由苛责你——对许多函数而言基本保证本身就是一个完全合理的选择。如果你的函数完全不提供任何异常安全保证那情况就不同了——在这个问题上基本可以认为“先假定你有罪除非你能自证清白”。你理应写出异常安全的代码。不过你也可能有一个强有力的辩护理由。再回头看那个调用了 f1 和 f2 的 someFunc。假设 f2 根本不提供任何异常安全保证连基本保证都没有。这意味着如果 f2 抛出异常程序可能在 f2 内部就已经泄露了资源也意味着 f2 可能已经破坏了数据结构——比如排好序的数组不再有序、从一个数据结构转移到另一个数据结构的对象丢失了等等。面对这些问题someFunc 根本无能为力。如果 someFunc 调用的那些函数本身不提供任何异常安全保证那么 someFunc 自己也不可能提供任何保证。这让我又想起了怀孕的事。女性要么怀孕要么没怀孕不存在“部分怀孕”这种状态。同样一个软件系统要么异常安全要么不是没有“部分异常安全”的系统。只要系统里有一个函数不具备异常安全性整个系统就不能算异常安全——因为调用那个函数就可能导致资源泄露和数据结构损坏。遗憾的是大量 C 遗留代码在编写时根本没有考虑异常安全所以如今许多系统都不是异常安全的它们掺入了以非异常安全方式编写的代码。我们没有理由让这种状况延续下去。在编写新代码或修改既有代码时请仔细思考如何让它具备异常安全性。从使用对象管理资源开始再次参见条款13这样就能防止资源泄露。接着为每个你编写的函数确定在实际可行的范围内你能提供三种异常安全保证中的哪一种最强级别——只有当调用遗留代码让你别无选择时才退而提供“无保证”。把你所做的决定记录下来既是为了函数的调用者也是为了未来的维护者。函数的异常安全保证是其接口的可见组成部分所以你应该像斟酌接口的其他方面一样审慎地选择它。四十年前充斥着 goto 的代码被认为是很好的实践如今我们追求结构化的控制流。二十年前全局可访问的数据被认为是很好的实践如今我们追求数据封装。十年前编写函数时不考虑异常的影响被认为是很好的实践如今我们追求写出异常安全的代码。时光流转我们也在不断学习与进步。切记1.异常安全函数不会泄露资源也不会让数据结构遭到破坏即使有异常抛出也是如此。这类函数提供基本保证、强保证或不抛出保证。2.强保证常可通过“拷贝并交换”手法实现但并非对所有函数都切实可行。3.一个函数能提供的异常安全保证通常不会强于它所调用的函数中最弱的那个保证。