Item 29:为异常安全而努力是值得的

对齐 Effective C++ 第三版 Item 29:从锁和图像更新失败出发,区分 no-leak、basic、strong、nothrow 保证,以 RAII 和 prepare-commit/copy-and-swap 构建可证明的异常安全,并处理外部副作用。

学习目标

  • 能区分 no-leak、basic、strong、nothrow guarantee,并描述异常后对象与外部状态
  • 能修改 delete/mutate-before-allocate 为 RAII candidate 加 noexcept commit,证明 copy-and-swap 强保证
  • 能分析 stream、database、network 等不可回滚副作用,设计 compensation、outbox 和失败注入测试
Exception guarantee ladderno leak → valid state → commit or rollback → no throwno-leak guaranteeRAII cleanup资源 owner 闭合basic guaranteevalid state值可部分改变strong guaranteecopy and swapcommit or rollbacknothrowswap / release不让异常逃出外部副作用另立边界stream positiondatabase transactiontransactional outboxcompensation内存对象 strong 不等于跨系统操作 strong;逐个 throw point 记录状态轨迹
RAII 先建立 no-leak floor,再按契约选择 basic、strong 或 nothrow;跨系统副作用需事务、outbox 或补偿。

失败状态实验

异常后究竟保证什么

先预测第 N 个 throw point 后的资源、对象值和外部状态,再切换保证等级查看验证产物。

保证承诺

异常后资源全部释放,对象仍满足 invariant,可析构、赋值和恢复;值可以部分改变。

设计

用 RAII cleanup boundary 管理 lock、memory 和 file,明确 partial progress contract。

当前场景 · no-leak / basic guarantee

异常后资源全部释放,对象仍满足 invariant,可析构、赋值和恢复;值可以部分改变。

从 changeBackground 的三个破坏点开始

菜单对象拥有背景图并记录更换次数:

class PrettyMenu {
public:
    void changeBackground(std::istream& imageSource);
 
private:
    Mutex mutex_;
    Image* background_;
    int imageChanges_;
};

一个脆弱实现先加锁、删除旧图、递增计数,再创建新图:

void PrettyMenu::changeBackground(std::istream& source) {
    lock(&mutex_);
    delete background_;
    ++imageChanges_;
    background_ = new Image(source);
    unlock(&mutex_);
}

new Image(source) 抛异常:mutex 永久锁住;background* 可能仍保存已释放地址;imageChanges* 已经增加却没有新图。

Item 29 的原则是 Strive for exception-safe code(为异常安全代码而努力)。捕获异常不是核心;核心是失败时系统仍处于可证明状态。

第一底线:异常不能泄漏资源

手工 lock/unlock 在异常路径跳过 unlock。先以 RAII guard 管理 mutex:

void PrettyMenu::changeBackground(std::istream& source) {
    std::scoped_lock lock(mutex_);
    // work
}

raw Image pointer 也应由 unique/shared owner 管理。RAII 解决锁和内存泄漏,但并不自动恢复 imageChanges_ 或旧图像。

“没有泄漏”是异常安全的必要条件,不是完整保证。

四层保证必须明确命名

basic 允许“有效但值不确定”;strong 提供 commit-or-rollback;nothrow 最强,适用于 destructor、deallocation、swap 等系统基础操作。

先构造新资源,再替换旧资源

不要先 delete old image。先创建 candidate owner:

void PrettyMenu::changeBackground(std::istream& source) {
    auto candidate = std::make_shared<Image>(source);
 
    std::scoped_lock lock(mutex_);
    background_.swap(candidate);
    ++imageChanges_;
}

若 Image construction 抛,candidate 未完成,background_/counter 不变。成功后锁内交换,candidate 接管旧图并在退出时释放。

这里 counter increment 对 int 不抛,因此 swap 后更新可安全完成;若 commit 包含多个可能失败成员,还需聚合 state 后一起 swap。

copy-and-swap 把完整状态作为事务

把相关数据放进 implementation state:

struct MenuState {
    std::shared_ptr<Image> background;
    std::uint64_t imageChanges;
 
    void swap(MenuState& other) noexcept {
        background.swap(other.background);
        std::swap(imageChanges, other.imageChanges);
    }
};

change 可以复制当前 state,在副本上构建新图和递增计数,最后 no-throw swap。

void PrettyMenu::changeBackground(std::istream& source) {
    MenuState next = state_;
    next.background = std::make_shared<Image>(source);
    ++next.imageChanges;
 
    std::scoped_lock lock(mutex_);
    state_.swap(next);
}

若 state_ 可能在 candidate 构建期间被其他线程修改,还需 version check 或锁内 snapshot;异常安全不替代并发一致性。

strong guarantee 依赖每个组成操作

函数整体通常只能提供最弱 sub-operation 的保证,除非它能 catch、rollback 或隔离其效果。

void update() {
    firstStrongOperation();
    secondBasicOperation();
}

若 second 抛后只保持 basic,update 无法仅凭 first strong 宣称 strong。

审查必须沿 call graph 追踪 guarantees,而不是只看当前函数代码。

basic guarantee 仍要求对象可继续使用

basic 不等于“没崩溃”。异常后所有 invariants 必须成立,资源无泄漏,对象可析构、可赋值,并且文档说明哪些值可能改变。

容器操作可能在失败前插入部分元素,但 size/capacity/element invariants 仍合法;用户可以 clear 或重试。

如果函数留下 dangling pointer、锁未释放或 size 与 storage 不匹配,连 basic 都没有。

nothrow 操作构成安全基础设施

destructor、resource release、rollback、swap 与 move 常被异常处理路径依赖,应尽量 nothrow。

static_assert(std::is_nothrow_swappable_v<MenuState>);
static_assert(std::is_nothrow_destructible_v<MenuState>);

不能虚假写 noexcept;内部异常逃出会 terminate。需要在边界 catch 并转换为 status 的操作,要明确是否真的“完成契约”还是 best-effort。

外部副作用通常不能自动回滚

读 stream 会推进 position,发送 network packet 会被外部观察,写 database 可能已经 commit。内存 copy-and-swap 无法撤销这些效果。

例如 Image constructor 从 stream 读取失败,PrettyMenu 可保持原值,但 stream 已消耗部分 bytes。函数对菜单是 strong,对 stream 不是 strong。

API 文档必须指出这种差异,或先把 input buffer snapshot 到可重放 storage。

database 和消息需要事务或补偿

跨系统更新不能假装一个 C++ swap 就能原子化。可使用 database transaction、outbox、idempotency key 或 compensation。

begin transaction
  update menu row
  insert event into outbox
commit
publisher sends event idempotently

这通常提供 eventual consistency,而不是进程内 strong guarantee;需要明确重试、重复和顺序。

锁范围与 candidate snapshot 要一致

为了减少持锁时间,可在锁外构建独立 Image;但复制 state/version 和 commit 必须防止 lost update。

for (;;) {
    auto [snapshot, version] = readState();
    auto candidate = buildNext(snapshot, source);
    if (tryCommit(version, std::move(candidate))) break;
}

异常安全、线程安全和进度策略必须一起测试:重试是否有界,source 能否重放,external effects 是否重复。

性能不能作为放弃保证的默认理由

copy whole state 可能昂贵,但先测量。可只复制会变化的 representation、使用 persistent data structure 或针对性 prepare/commit。

如果业务只能承担 basic guarantee,也应明确选择并提供 recovery API,而不是让实现偶然处于某状态。

保证是接口的一部分,性能优化必须维持或显式版本化改变它。

对每个 throw point 做失败注入

先预测每个 throw point 发生后 old image、counter、mutex、stream position 与外部事件分别处于什么状态,再运行注入测试核对实际轨迹。

代码阅读无法覆盖 allocator、copy constructor、decoder 和 callback 的所有异常位置。测试要逐点注入。

验证矩阵包括:

  • lock guard 在每个 throw point 后都释放 mutex。
  • allocation/decoder 失败时 old image、counter、version 保持原值。
  • basic-only operation 失败后 invariant、resource ledger 与 recovery API 有效。
  • swap/destructor/rollback 路径不抛,traits 与 runtime instrument 一致。
  • stream position、database row、outbox event 分别记录可观察效果。
  • concurrent commit 覆盖 stale version、retry、source replay 和 cancellation。
  • success path 只释放旧 image 一次,notification 不重复。

只有每行都有自动化证据,strong/basic/nothrow 才不是口号。

小结

  • exception-safe code 必须同时处理资源泄漏、对象不变量和可观察状态
  • RAII 提供 no-leak floor,但不能自动回滚计数、容器或外部副作用
  • basic 保证有效状态,strong 保证失败无效果,nothrow 承诺操作不失败
  • prepare candidate 后以 noexcept swap commit,可用 copy-and-swap 构建 strong guarantee
  • 外层保证受最弱不可隔离操作限制;stream/network/database 需单独声明或事务补偿
  • deterministic failure injection 与 exception guarantee matrix 用于逐点证明保证

资料与写作方式声明

本章以Effective C++, Third Edition, Item 29权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

exception-safe code

异常后仍满足明确资源与状态契约的代码。

failure-point analysis

列出可抛点及失败状态的分析。

no-leak guarantee

异常路径仍释放全部已取得资源。

RAII cleanup boundary

由析构统一执行资源清理的边界。

no exception guarantee

异常后没有可依赖状态承诺。

basic guarantee

无泄漏且对象保持有效但值可能改变。

strong guarantee

失败时所有可观察状态保持原值。

nothrow guarantee

操作承诺不让异常离开并完成契约。

prepared candidate

目标之外构建的完整临时状态。

prepare-then-commit

先准备、后以不可失败操作发布。

copy-and-swap
复制修改候选后以 swap 提交。
noexcept commit operation

不会抛异常的最终状态发布。

failure isolation

让可抛工作只影响临时 candidate。

valid post-failure state

失败后仍可析构、恢复和继续使用的状态。

partial progress contract

允许保留部分效果但保持一致的契约。

no-fail primitive

栈展开和提交依赖的不可失败基础操作。

nothrow trait assertion

编译期验证不抛能力的约束。

external side effect

进程对象之外可观察且难回滚的效果。

multi-resource guarantee boundary

分别声明各资源失败保证的边界。

transactional outbox

状态与待发事件同事务持久化的模式。

compensating action

修正已发生外部效果的语义操作。

stale-candidate overwrite

旧 snapshot candidate 覆盖并发新状态。

optimistic commit validation

按版本校验后提交或重试。

guarantee-preserving optimization

不降低保证的 candidate/commit 优化。

deliberate guarantee level

按领域成本明确选择的失败承诺。

deterministic failure injection

在指定可抛点强制异常的测试。

exception guarantee matrix

失败点、状态和保证的验证表。

练习

  1. 问题 1:修复 PrettyMenu(exception-safe code、no-leak guarantee、basic guarantee)。 原实现手工锁、delete old image、递增计数后 new,请给出 strong guarantee 方案。
  1. 问题 2:组合保证(strong guarantee、weakest-link guarantee、copy and swap)。 update 先调用 strong 内存更新,再调用只能 basic 的插件 callback,外层能承诺什么,怎样提升?
  1. 问题 3:处理数据库与消息(nothrow guarantee、transactional outbox、compensating action)。 更新菜单记录后发送事件,任一步失败都可能状态分裂,请设计边界。

讨论

评论区加载中…