Skip to main content

Chapter 9: Exception Safety

STL 的异常安全讨论的是一个具体问题:容器或算法执行到一半时,构造、拷贝、移动、分配器或用户提供的回调抛出异常,已经存在的对象状态应当保持到什么程度。读完本章后,读者应能追踪一次 std::vector::push_back 扩容失败时的状态变化,判断它能给出 basic、strong 还是 no-throw guarantee,并从源码形状中识别提交点和回滚路径。

本章的贯穿材料是一段向 std::vector<T> 追加元素的代码。追加操作表面上只有一个 API 调用,内部却串起新存储分配、旧元素迁移、新元素构造、旧存储销毁和三指针状态更新。异常可能出现在每个阶段,异常安全的核心就是让这些阶段的对象状态可恢复、资源责任可关闭、对外承诺可描述。

#include <stdexcept>
#include <string>
#include <vector>

struct Record {
std::string payload;

Record(std::string value) : payload(std::move(value)) {}
Record(const Record&) = default;
Record(Record&& other) noexcept : payload(std::move(other.payload)) {}
};

void append_record(std::vector<Record>& records) {
records.push_back(Record{"new-record"});
}

这段代码的关键点在 Record 的移动构造带有 noexcept。当 vector 扩容时,库实现可以把旧元素迁移到新存储,并在所有新对象构造完成后再提交新的 begin / end / capacity 状态。若中途失败,容器仍能销毁已经构造的新对象、释放新存储,并保留旧存储上的旧元素。这个“先准备新状态,再一次性提交”的形状,会贯穿本章所有小节。

9.1 basic、strong 与 no-throw guarantee

异常安全承诺描述的是异常离开函数后,对象还能维持到什么状态。它关注两个层面:资源是否泄漏,对象值是否保持调用前的样子。STL 文档和源码常围绕 basic guarantee、strong guarantee、no-throw guarantee 组织实现路径。

basic guarantee 的工作定义是:异常发生后,对象仍保持有效状态,资源责任仍然闭合,但对象的具体值可能已经改变。对容器来说,有效状态通常意味着内部不变量仍成立,例如 size() 可以被读取、析构可以正常执行、后续合法操作可以继续进行。basic guarantee 对值保持的要求较低,但对“不变量完整”和“资源释放”要求仍然严格。

strong guarantee 的工作定义是:操作要么完整成功,要么对目标对象没有可观察效果。这个承诺也常被称为 commit / rollback 语义。它通常依赖两段式结构:先在临时区域完成可能失败的工作,再在一个极短的提交点修改目标对象状态。对 vector 扩容来说,新存储就是临时区域,三指针更新就是提交点。

no-throw guarantee 的工作定义是:该操作承诺不向外传播异常。它常用于析构、释放、交换内部指针、移动某些资源句柄等路径。no-throw guarantee 是最高等级的调用侧承诺,因为调用者可以把它放进清理路径、析构路径或提交路径中。若清理路径自身可能抛出异常,回滚逻辑就会被新的异常打断,原先的异常安全分析会失去稳定支点。

下面的表格把三种承诺放到同一组维度中比较,重点看异常后对象状态、资源状态和可迁移判断。

承诺异常后对象状态资源状态典型 STL 场景调用侧判断
basic guarantee对象有效,值可能改变资源闭合部分插入、复杂赋值、用户回调参与的修改后续可析构、可继续使用,但要重新检查值
strong guarantee对象保持调用前状态新建资源被清理vector 扩容追加、临时缓冲排序准备阶段失败后可按旧状态继续
no-throw guarantee操作不传播异常清理或交换路径稳定完成析构、释放、指针交换、部分移动构造可放入回滚路径和提交路径

异常安全等级是接口承诺,也是实现约束。接口写着 strong guarantee,源码就需要出现临时对象、guard、提交点或等价结构;接口只给 basic guarantee,源码仍需要关闭资源责任和维护不变量。阅读 STL 源码时,先确认接口承诺,再寻找支撑该承诺的对象状态变化,比直接陷入模板辅助层更可靠。

回到本章贯穿材料,records.push_back(Record{"new-record"}) 在扩容时希望达到 strong guarantee:若分配新存储失败,旧 records 原封保留;若迁移旧元素失败,已经构造到新存储的元素被销毁,新存储被释放,旧 records 仍指向旧存储。若元素类型的移动构造可能抛出并且缺少可用拷贝路径,强承诺会受到元素类型约束影响,这个边界会在下一节展开。

9.2 noexcept 与 move_if_noexcept 的选择规则

noexcept 是异常安全进入类型选择的入口。它既可以出现在函数声明上,向调用者承诺函数不传播异常,也可以被类型萃取读取,参与 STL 内部的 move / copy 路径选择。对容器扩容来说,旧元素从旧存储进入新存储时,选择移动还是拷贝,会直接影响 strong guarantee 是否可实现。

std::move 只把表达式转换成可移动的值类别,它不检查移动构造会不会抛出。std::move_if_noexcept 的规则更接近容器扩容的需求:当 T 的移动构造为不抛异常,或者 T 没有可用拷贝构造时,它返回右值引用;其余情况下,它返回 const T&,让调用点优先走拷贝构造。这个规则可从 std::move_if_noexcept 的文档说明中直接看到,它服务于 strong exception guarantee 的实现需求:std::move_if_noexcept

最小代码可以把这个选择暴露出来。下面的类型有三种不同组合:可无异常移动、移动可能抛但可拷贝、只能移动且移动可能抛。

#include <type_traits>
#include <utility>

struct NoThrowMove {
NoThrowMove() = default;
NoThrowMove(const NoThrowMove&) = default;
NoThrowMove(NoThrowMove&&) noexcept = default;
};

struct ThrowingMoveCopyable {
ThrowingMoveCopyable() = default;
ThrowingMoveCopyable(const ThrowingMoveCopyable&) = default;
ThrowingMoveCopyable(ThrowingMoveCopyable&&) {}
};

struct MoveOnlyThrowing {
MoveOnlyThrowing() = default;
MoveOnlyThrowing(const MoveOnlyThrowing&) = delete;
MoveOnlyThrowing(MoveOnlyThrowing&&) {}
};

static_assert(std::is_same_v<decltype(std::move_if_noexcept(std::declval<NoThrowMove&>())), NoThrowMove&&>);
static_assert(std::is_same_v<decltype(std::move_if_noexcept(std::declval<ThrowingMoveCopyable&>())), const ThrowingMoveCopyable&>);
static_assert(std::is_same_v<decltype(std::move_if_noexcept(std::declval<MoveOnlyThrowing&>())), MoveOnlyThrowing&&>);

第一种类型走移动路径,因为移动构造承诺不传播异常。第二种类型走拷贝路径,因为拷贝不会改变旧对象,即使新存储中的拷贝构造失败,旧存储仍可作为回滚目标。第三种类型只能走移动路径,因为没有拷贝构造可选;若移动构造执行到一半后抛出,旧对象可能已经进入 moved-from 状态,容器就缺少“恢复旧值”的材料。

这个规则解释了一个常见现象:给元素类型的移动构造补上正确的 noexcept,可能改变 vector 扩容时选择的路径。标准库页面对 vector::push_backvector::emplace_back 都记录了这一类边界:追加操作通常提供 strong exception guarantee;当元素移动构造可能抛出且类型又无法拷贝时,容器可能使用会抛出的移动构造,异常时强承诺会被放宽,效果进入未指定范围:vector::push_backvector::emplace_back

因此,STL 异常安全的判断顺序应从元素类型开始:先看移动构造是否 noexcept,再看拷贝构造是否可用,再看容器操作是否需要迁移已有元素,最后判断接口承诺是否能由这条路径支撑。noexcept 在这里作为类型证据,决定容器能否把“扩容失败后保持旧状态”落到实现上。

9.3 RAII、commit / rollback 与资源恢复

RAII 在异常安全中的作用是把资源释放绑定到对象生命周期。只要控制流离开作用域,无论是正常返回还是异常传播,局部对象都会析构。STL 实现利用这一点构造 guard 对象:guard 在构造时接管临时资源,在失败路径析构时清理资源,在提交成功后解除清理责任。

commit / rollback 是 strong guarantee 的典型实现结构。commit 表示从旧状态切换到新状态的点;rollback 表示提交前失败时清理临时工作并保留旧状态。一个合格的提交点应当尽量短,最好只做不抛异常的指针赋值、整数赋值或资源句柄交换。可能抛出的构造、分配、拷贝、移动,应尽量放在提交点之前完成。

下面是一段教学简化代码,用来表达 vector 扩容时 guard 的形状。它属于教学简化代码,保留了异常安全分析需要看的对象关系。

#include <memory>
#include <utility>

template <class T, class Alloc>
class raw_buffer_guard {
public:
raw_buffer_guard(Alloc& alloc, T* data, std::size_t capacity)
: alloc_(alloc), data_(data), capacity_(capacity), constructed_(0), active_(true) {}

void one_more_constructed() noexcept {
++constructed_;
}

void release() noexcept {
active_ = false;
}

~raw_buffer_guard() {
if (!active_) {
return;
}
for (std::size_t i = 0; i < constructed_; ++i) {
std::allocator_traits<Alloc>::destroy(alloc_, data_ + i);
}
std::allocator_traits<Alloc>::deallocate(alloc_, data_, capacity_);
}

private:
Alloc& alloc_;
T* data_;
std::size_t capacity_;
std::size_t constructed_;
bool active_;
};

这段 guard 保存四类状态:分配器引用、临时存储地址、临时容量、已经构造完成的元素数量。异常发生时,析构函数只销毁已经完成构造的对象,再释放整块临时存储。它不会触碰旧 vector 的 begin / end / capacity,因为提交尚未发生。这个边界让旧状态成为 rollback 目标。

RAII guard 只解决资源恢复,强承诺还需要提交顺序配合。若源码先改 vector 的内部指针,再开始迁移旧元素,迁移失败时旧指针状态已经丢失,回滚会变得困难。若源码先在新存储中完成所有构造,再更新内部指针,失败路径可以完全局限在临时存储内部。这就是从源码中识别异常安全设计信号的核心方法:看谁先被修改,看失败路径还能拿到哪些旧状态,看清理责任由哪个对象承担。

资源恢复还包含一个边界:析构和 deallocate 路径应当稳定完成。标准容器依赖元素析构、allocator 的释放路径和内部 guard 的析构完成清理。用户自定义析构函数若向外传播异常,会破坏很多常规容器操作的清理假设。工程上,元素析构函数应保持不传播异常,释放函数和回滚 guard 也应按 no-throw 路径设计。

9.4 vector 扩容中的异常安全路径

vector 扩容是观察 STL 异常安全的最好入口,因为它同时涉及连续内存、元素生命周期、allocator、move / copy 选择和迭代器失效。vector::push_back 文档说明了追加元素后若新 size 超过旧 capacity,会发生重新分配;重新分配会使迭代器和引用失效,并且异常时通常要求函数没有可观察效果:vector::push_back

扩容路径可以拆成五个阶段。第一阶段计算新容量并分配原始存储。第二阶段在新存储中构造追加元素或预留追加位置。第三阶段把旧元素迁移到新存储,迁移策略由上一节的 move_if_noexcept 规则影响。第四阶段销毁旧存储上的元素并释放旧存储。第五阶段更新内部三指针,让容器正式指向新存储。

下面的图只描述扩容追加的异常安全骨架,省略具体增长倍率和实现差异。

图里的核心分界是提交三指针。提交之前,旧 vector 状态仍然存在,guard 持有新存储的清理责任;提交之后,容器已经切换到新存储,旧存储进入清理路径。为了支撑 strong guarantee,提交之前的阶段可以抛出,但必须有完整回滚材料;提交点本身应由不抛异常的状态更新组成。

教学简化版扩容函数可以写成下面这样。它刻意保留 allocator 调用、move_if_noexcept 和提交点,省略迭代器、增长策略、自插入和边界检查。

#include <memory>
#include <utility>

template <class T, class Alloc>
void reallocate_append(T*& begin, T*& end, T*& cap, Alloc& alloc, T&& value) {
const std::size_t old_size = static_cast<std::size_t>(end - begin);
const std::size_t new_capacity = old_size == 0 ? 1 : old_size * 2;

T* new_begin = std::allocator_traits<Alloc>::allocate(alloc, new_capacity);
raw_buffer_guard<T, Alloc> guard{alloc, new_begin, new_capacity};

std::size_t i = 0;
for (; i < old_size; ++i) {
std::allocator_traits<Alloc>::construct(
alloc,
new_begin + i,
std::move_if_noexcept(begin[i])
);
guard.one_more_constructed();
}

std::allocator_traits<Alloc>::construct(alloc, new_begin + old_size, std::move(value));
guard.one_more_constructed();

T* old_begin = begin;
T* old_end = end;
T* old_cap = cap;

begin = new_begin;
end = new_begin + old_size + 1;
cap = new_begin + new_capacity;
guard.release();

for (T* p = old_begin; p != old_end; ++p) {
std::allocator_traits<Alloc>::destroy(alloc, p);
}
std::allocator_traits<Alloc>::deallocate(alloc, old_begin, static_cast<std::size_t>(old_cap - old_begin));
}

这段简化代码的异常安全点有三个。第一,new_begin 由 guard 管理,提交前失败会自动销毁已构造元素并释放新存储。第二,旧三指针在提交前保持不变,失败路径仍能使用旧 beginendcap。第三,迁移旧元素使用 std::move_if_noexcept,元素类型允许时优先选择能维持旧状态的路径。

真实标准库实现还会处理更多边界,例如插入位置在中间、追加实参引用了容器内部元素、增长容量达到上限、allocator traits 的传播规则、调试 iterator 状态等。源码细节会不同,但异常安全骨架仍可按“分配新存储 → 构造新对象 → 迁移旧对象 → 提交 → 清理旧存储”读取。

9.5 allocator 与容器插入失败恢复

allocator 把容器的原始存储分配和对象生命周期操作统一到一个策略接口下。容器通过 std::allocator_traits<Alloc>::allocate 获取原始存储,通过 construct 在指定地址建立对象,通过 destroy 结束对象生命周期,通过 deallocate 释放原始存储。vector::emplace_back 的文档也说明,新元素通常经由 allocator_traits::construct 在容器提供的位置原地构造:vector::emplace_back

异常安全分析必须区分原始存储和已经构造的对象。allocate 成功后,只得到一段尚未包含 T 对象的存储;construct 成功一次,才多出一个需要析构的 T 对象。若第 k 次 construct 抛出,容器只能销毁前 k 个已经完成构造的对象,然后释放整块原始存储。销毁未完成构造的地址会进入未定义行为,因此 guard 必须记录 constructed count。

插入失败的恢复责任可以按照失败点划分。分配失败时,新存储还没有出现,旧容器状态保持原样。新元素构造失败时,新存储存在,但旧元素尚未迁移或尚未提交,guard 释放新存储。旧元素迁移失败时,guard 销毁已经迁移完成的新元素并释放新存储,旧存储作为回滚目标。提交后清理旧存储时,代码应只执行不传播异常的销毁和释放路径。

allocator 还会影响容器赋值、移动和交换时的异常边界。allocator_traits 中的 propagation 规则决定容器拷贝赋值、移动赋值、交换时 allocator 是否随容器状态传播。若 allocator 不相等且不能传播,容器可能需要逐元素迁移或重新分配,异常安全路径就会从简单指针交换变成元素级构造与回滚。这个主题会在容器章节继续展开,本章只固定一个判断:allocator 既决定“从哪里分配内存”,也决定失败路径由谁释放、释放时需要哪些参数、容器状态能否通过交换快速提交。

用源码阅读视角看 allocator 参与路径,应优先找四个调用点:allocateconstructdestroydeallocate。再看它们周围是否有 guard 记录已构造数量。最后看提交点是否发生在所有可能抛出的构造动作之后。这个顺序可以把大量模板包装压缩成一个对象生命周期问题。

9.6 STL 源码中的异常安全设计信号

阅读 STL 源码时,异常安全设计通常不会以“strong guarantee”几个字直接出现。它会表现为一些稳定形状:临时缓冲、guard 对象、已构造计数、scope cleanup、提交点、noexcept 条件、move_if_noexcept 分支、allocator traits 调用、catch 后清理并重新抛出。这些信号共同说明实现正在把失败路径纳入对象状态设计。

第一个信号是临时缓冲。凡是容器修改需要保持旧状态,源码常会先分配新缓冲或构造临时节点。临时缓冲说明实现把可能失败的工作隔离在目标对象之外。对 vector 是新连续存储;对节点式容器是新节点;对算法可能是临时数组或局部状态对象。

第二个信号是 guard 或 scope cleanup。guard 的成员通常包含资源指针、分配器、构造数量、激活标记。它的析构函数执行清理,成功路径会调用 releasecommit 或通过移动所有权解除清理责任。看到这类对象,应立即追踪它接管了哪些资源,析构时清理哪些对象,在哪个点解除责任。

第三个信号是提交点。提交点附近通常会出现内部指针更新、size 更新、节点链接、swap 或所有权转移。异常安全分析要判断提交点之前还有没有可能抛出的用户代码或分配动作。提交点越短,strong guarantee 越容易成立;提交点夹杂用户构造、比较器调用或 allocator 分配,失败路径就需要额外恢复逻辑。

第四个信号是类型条件。源码可能使用 noexcept(...)is_nothrow_move_constructibleis_copy_constructiblemove_if_noexcept、concept 或 traits 来选择路径。这些条件直接决定实现选择“可回滚的拷贝路径”或“可能改变旧对象的移动路径”。当元素类型的 move / copy 能力改变,容器扩容的异常安全边界也可能改变。

第五个信号是 catch 清理并重新抛出。常见实现会在构造循环周围放置 try / catch,catch 中销毁已经完成构造的对象、释放临时存储,然后继续抛出异常。RAII guard 可以替代显式 catch,但语义相同:异常传播给调用者,临时资源由本层清理,旧对象状态按接口承诺保留或维持有效。

把这些信号合并成源码阅读顺序,可以得到本章的可迁移方法。先确认接口承诺,再定位可能抛出的点;先找临时资源和 guard,再找提交点;先判断旧状态在失败前是否仍可访问,再判断清理路径是否不传播异常;最后用元素类型的 noexcept 和 copy / move 能力修正结论。这个顺序能直接用于后续 vectorstringdeque、有序节点容器和哈希容器章节。

最小自检任务

阅读下面代码,只根据本章内容判断:items.emplace_back(42) 在触发 vector 扩容时,标准库实现更可能选择移动旧元素还是拷贝旧元素?若迁移旧元素过程中抛出异常,items 的 strong guarantee 是否稳定?说明判断步骤。

#include <vector>

struct Item {
int value{};

Item(int v) : value(v) {}
Item(const Item&) = default;
Item(Item&& other) {
value = other.value;
other.value = -1;
}
};

void append_item(std::vector<Item>& items) {
items.emplace_back(42);
}

答案要点

Item 的移动构造没有标注 noexcept,并且 Item 有可用拷贝构造。vector 扩容迁移旧元素时,为了支撑 strong guarantee,更可能通过 std::move_if_noexcept 一类规则选择拷贝旧元素。拷贝旧元素失败时,旧存储中的旧元素没有被修改,guard 可以清理新存储中已经构造的对象并释放新存储,items 仍保持调用前状态。

若实现强行使用会抛出的移动构造,移动构造已经把 other.value 改成 -1 后再抛出时,旧存储中的元素值可能已经改变。此时旧状态缺少完整回滚材料,strong guarantee 难以稳定成立。这个例子说明异常安全判断要先看元素类型的 move / copy 能力,再看容器扩容是否需要迁移旧元素,最后看提交点之前是否保留旧状态。

本章知识点总结

  • 异常承诺:异常安全等级描述异常传播后对象状态、资源状态和调用侧可继续使用的边界。
  • basic 保证:basic guarantee 要求对象仍有效、资源责任闭合,但对象值可以发生变化。
  • strong 保证:strong guarantee 要求操作成功时提交新状态,失败时保留调用前状态。
  • no-throw 保证:no-throw guarantee 让清理、析构、交换和提交路径可以作为稳定支点。
  • noexcept 证据noexcept 会进入类型萃取,影响容器在移动和拷贝之间的选择。
  • 迁移选择std::move_if_noexcept 在无异常移动或无拷贝路径时返回右值引用,其余可拷贝场景返回 const T&
  • RAII guard:guard 把临时资源清理绑定到析构,失败路径可以自动销毁已构造对象并释放存储。
  • 提交点:提交点应放在可能抛出的构造、迁移和分配之后,并由不传播异常的状态更新组成。
  • vector 扩容vector 扩容通常按分配新存储、构造新对象、迁移旧对象、提交三指针、清理旧存储推进。
  • allocator 边界:allocator 参与原始存储分配、对象构造、对象销毁和存储释放,失败恢复必须区分存储和对象生命周期。
  • 源码信号:临时缓冲、已构造计数、scope cleanup、move_if_noexcept 分支和提交点都是异常安全设计信号。
  • 判断顺序:先确认接口承诺,再定位抛出点,然后检查临时资源、guard、提交点和元素类型能力。