Effective C++ 学习笔记 条款29 为“异常安全”而努力是值得的
2026/7/12 18:08:31 网站建设 项目流程

异常安全有点像怀孕……不过先别急着往下想。在讨论“生育”之前,我们还得先把“恋爱”阶段走完。

假设我们有一个表示带背景图像的 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.一个函数能提供的异常安全保证,通常不会强于它所调用的函数中最弱的那个保证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询