⚡C++ 内存管理

C++ 异常安全与强保证

面试回答

常见问法

  • 什么是异常安全?为什么 C++ 要强调异常安全级别?
  • 基本保证、强保证、不抛异常保证分别是什么意思?
  • copy-and-swap 为什么常用于实现强异常安全?
  • 为什么很多 swap 要写成 noexcept
  • 为什么标准库容器特别关注“移动构造是否为 noexcept”?
  • 强保证一定比基本保证更好吗?工程上怎么选?

回答

异常安全讨论的核心不是“会不会抛异常”,而是:

一旦操作失败并抛异常,程序状态还能不能保持正确,资源会不会泄漏,对象还能不能继续使用。

C++ 里常说的异常安全通常分三档:

  1. 基本保证(basic guarantee) 如果操作抛异常:

    • 不发生资源泄漏
    • 不破坏对象不变式(invariant)
    • 对象仍处于有效但未指定的状态,可以析构、可以继续赋值、通常也能继续使用
  2. 强保证(strong guarantee) 如果操作抛异常:

    • 操作像没有发生过一样
    • 对象状态保持不变 也就是常说的 commit or rollback(提交或回滚)
  3. 不抛异常保证(no-throw guarantee) 操作承诺绝不会抛异常。 这类操作最典型的是析构函数、swap、移动操作的一部分基础设施函数。

这些概念重要,是因为 C++ 允许我们自己管理资源、自己定义对象语义。 一旦异常发生,如果资源释放、对象状态恢复、提交时机设计不好,就可能出现:

  • 内存泄漏
  • 半更新状态
  • 对象不变式被破坏
  • 容器回滚失败
  • 程序逻辑悄悄出错,而不是直接崩溃

一个典型实现强保证的手法是 copy-and-swap

#include <vector>
#include <utility>

class Buffer {
public:
    void swap(Buffer& other) noexcept {
        data_.swap(other.data_);
    }

    Buffer& operator=(Buffer other) { // 按值传参,先构造副本
        swap(other);                  // 提交阶段不抛异常
        return *this;
    }

private:
    std::vector<int> data_;
};

这里的关键点是:

  • 先构造参数 other,这一步可能失败
  • 如果失败,当前对象完全不变
  • 如果成功,再通过 swap 原子式提交
  • swap 通常要求 noexcept,否则“提交阶段”也可能失败,强保证就站不住了

追问

1. 为什么 swap 经常要求 noexcept

因为很多强保证实现都依赖“最后一步提交不能失败”。 如果 swap 也可能抛异常,那么:

  • 新状态准备好了,但提交时又失败
  • 当前对象和临时对象都可能进入中间状态
  • 强保证无法成立

所以 swap 往往被设计成纯资源句柄交换,不做分配、不做复杂逻辑,从而做到 noexcept


2. 为什么标准库容器很看重移动构造是否 noexcept

std::vector 扩容为例,扩容时需要把旧元素搬到新内存里。 如果元素的移动构造可能抛异常,那么容器为了维持强保证,往往更倾向于:

  • 拷贝 而不是移动 因为拷贝失败时更容易保持旧数据不变。

如果移动构造是 noexcept,容器就可以更放心地移动元素:

  • 性能更好
  • 回滚策略更简单
  • 更容易满足强保证或至少较强的异常安全语义

这也是为什么自定义类型如果移动操作确实不会失败,应该显式标注 noexcept


3. 强保证通常要付出什么代价?

强保证不是免费的,常见代价包括:

  • 额外临时对象
  • 额外内存分配
  • 拷贝/移动成本
  • 代码结构更复杂
  • 某些场景下无法做到真正回滚

所以工程里不是所有接口都追求强保证。 常见选择原则是:

  • 基础设施、容器、通用库接口:尽量做强保证
  • 高成本操作、流式处理、外部系统交互:通常只能做到基本保证
  • 析构、释放、回收、交换、解锁:尽量不抛异常

4. RAII 和强保证是什么关系?

很多人会混淆这两个概念。 它们不是一回事:

  • RAII 解决的是:异常发生时,资源是否自动释放,防止泄漏
  • 强保证 解决的是:异常发生时,对象逻辑状态是否回到操作前

所以:

  • 只有 RAII,不代表对象状态不变
  • 只有回滚思路,没有 RAII,也可能在失败路径上泄漏资源

一句话区分:

RAII 解决资源安全,强保证解决状态回滚。


5. “对象有效但未指定状态”是什么意思?

这是基本保证里的高频表述。意思是:

  • 对象仍满足类不变式
  • 可以安全析构
  • 可以重新赋值
  • 但具体值不承诺是什么

比如移动后的标准库对象,通常就是“有效但未指定状态”。 面试里要强调:有效 ≠ 值不变,也不等于还能按原语义正常使用。


6. 哪些场景很难做到强保证?

以下场景通常难以完全回滚:

  • 已经写文件、发网络请求、写数据库
  • 已经对外暴露副作用
  • 部分更新无法撤销
  • 涉及锁、线程、跨模块状态同步

这类场景通常采用:

  • 基本保证
  • 日志 + 补偿
  • 事务
  • 两阶段提交
  • 幂等设计

所以强保证更适合内存内对象状态变更,不一定适合所有业务动作。


7. 析构函数为什么一般不能抛异常?

因为如果栈展开过程中又有析构函数抛异常,会导致 std::terminate。 因此工程上通常遵循:

  • 析构函数默认应视为不抛
  • 资源释放失败也不要在析构里传播异常
  • 如确实需要报告错误,应改用显式 close() / commit() 接口

8. noexcept 只是优化提示吗?

不是。它有两个层面的意义:

  • 语义承诺:告诉调用方“这里不会抛”
  • 优化与策略选择依据:标准库会据此决定走移动还是拷贝、能否用某些异常安全实现

所以 noexcept 既影响性能,也影响异常安全策略。

原理展开

1. 异常安全的本质:失败路径上的正确性

异常安全本质上是在定义:

操作失败后,资源、对象状态、程序不变式分别还能保持到什么程度。

很多人只盯着 try/catch,但面试真正要讲的是三件事:

  • 资源有没有泄漏
  • 对象有没有被破坏
  • 调用方能不能继续信任这个对象

异常安全是一种接口契约,不是语法技巧。


2. 基本保证:底线是“别坏掉”

基本保证是最基础、也最现实的一档。 它不要求“状态完全不变”,只要求:

  • 资源都被正确清理
  • 对象依然满足不变式
  • 对象还能被安全析构或重新赋值

例如一个自定义容器在插入元素失败后,哪怕元素个数没达到预期,只要:

  • 内部指针没悬空
  • 已构造对象都能正确析构
  • 容器内部结构仍一致 那它就满足基本保证。

一个典型判断标准是:

失败后,这个对象还能不能安全地“活下去”。


3. 强保证:本质是“先准备,再提交”

强保证的核心设计思想是:

把操作拆成“可能失败阶段”和“不可失败提交阶段”。

常见流程:

  1. 准备新资源 / 新状态
  2. 可能失败的步骤都在临时对象上完成
  3. 最后用一个不抛异常的提交动作替换旧状态

这就是 commit-or-rollback 思想。

典型代码:

#include <vector>
#include <utility>

class Buffer {
public:
    Buffer& operator=(const Buffer& rhs) {
        if (this == &rhs) return *this;

        Buffer tmp(rhs); // 可能抛异常,但不影响当前对象
        swap(tmp);       // 不抛异常,提交新状态
        return *this;
    }

    void swap(Buffer& other) noexcept {
        data_.swap(other.data_);
    }

private:
    std::vector<int> data_;
};

如果 tmp(rhs) 失败,当前对象完全不变。 如果成功,swap 一次提交。 这就是强保证最经典的工程化写法。


4. 不抛异常保证:给“基础动作”兜底

不抛异常保证通常用于那些:

  • 必须在清理路径执行的操作
  • 作为其他强保证实现基础的操作
  • 一旦失败,整个系统恢复逻辑会很麻烦的操作

典型包括:

  • 析构函数
  • swap
  • move(在确实安全时)
  • 解锁、释放句柄、重置指针等基础动作

面试里可以说:

不抛异常保证通常不是为了“更高级”,而是为了给系统建立一个可靠的地基。


5. RAII 是异常安全的基础,但不等于强保证

RAII 能保证:

  • 构造成功后,资源由对象接管
  • 作用域退出时自动释放
  • 即使异常传播,也不会漏掉清理

例如:

#include <memory>

void f() {
    auto p = std::make_unique<int[]>(1024); // 自动管理资源
    // 后续逻辑如果抛异常,p 仍会自动释放
}

这能防止泄漏,但并不能自动保证“业务状态没变”。

例如下面虽然没有资源泄漏,但不一定有强保证:

class Account {
public:
    void updateName(const std::string& s) {
        name_.clear();
        name_ = s; // 这里可能抛异常
    }

private:
    std::string name_;
};

如果 clear() 后赋值失败,name_ 仍然有效,但原值丢了。 这最多是基本保证,不是强保证。


6. 为什么 copy-and-swap 能自然提供强保证

copy-and-swap 的核心优势有三点:

(1)失败点集中在副本构造阶段

只要副本没构造成功,原对象不受影响。

(2)提交逻辑统一

赋值最终都收敛成一次 swap,逻辑清晰,代码更稳。

(3)天然处理自赋值

按值传参时,形参本身就是一份副本,自赋值通常无需额外复杂逻辑。

示例:

class Buffer {
public:
    Buffer() = default;
    Buffer(const Buffer&) = default;
    Buffer(Buffer&&) noexcept = default;

    Buffer& operator=(Buffer other) {
        swap(other);
        return *this;
    }

    void swap(Buffer& other) noexcept {
        data_.swap(other.data_);
    }

private:
    std::vector<int> data_;
};

但也要注意: copy-and-swap 不一定总是最优,因为它可能引入额外拷贝,尤其对象很大、赋值频繁时要考虑成本。


7. 容器为什么依赖 noexcept move

std::vector 扩容时的简化逻辑:

  1. 分配新内存
  2. 将旧元素搬到新内存
  3. 搬运全部成功后,销毁旧内存并提交

这里如果“搬运”用的是移动构造,而移动构造可能抛异常,那么搬到一半失败时,旧区和新区都可能有部分对象,处理会复杂很多。 为了维持强保证,标准库往往采取:

  • 能安全移动就移动
  • 移动不安全就退回拷贝

这就是为什么一个类型的移动构造如果真的不会失败,最好写成:

class X {
public:
    X(X&&) noexcept = default;
    X& operator=(X&&) noexcept = default;
};

8. 强保证不是默认目标,要看代价和收益

工程里常见判断是:

适合追求强保证的场景

  • 值语义对象
  • 容器类
  • 可回滚的内存内状态更新
  • 公共库接口
  • 容易被复用、出错代价高的基础组件

更适合基本保证的场景

  • 大量数据处理
  • 增量式更新
  • 性能敏感路径
  • 外部副作用不可撤销
  • 回滚成本过高

所以面试里不要说“强保证一定最好”,更成熟的说法是:

强保证更稳,但成本更高;是否值得,要看对象语义、性能预算和副作用是否可回滚。


9. 类设计时如何主动争取异常安全

工程实践里可以从下面几个方向做设计:

(1)优先使用 RAII 管理资源

少写裸 new/delete,多用:

  • std::unique_ptr
  • std::shared_ptr
  • std::vector
  • std::string
  • 锁守卫等

(2)维护清晰的不变式

类要有明确规则,例如:

  • 指针为空时长度必须为 0
  • 已初始化和未初始化状态如何区分
  • 任何时刻对象都要满足哪些约束

(3)把修改拆成准备阶段和提交阶段

先在局部对象、临时缓冲区完成可能失败的操作,再原子提交。

(4)给基础操作加 noexcept

前提是真正不会抛。不能为了“好看”乱加,否则异常逃出 noexcept 会直接 std::terminate

(5)显式区分“可能失败操作”和“清理操作”

例如:

  • close() 可以返回错误
  • 析构函数只兜底,不传播异常

10. 一个更典型的强保证示例

下面这个版本比“手工回滚”更符合 C++ 风格:

#include <algorithm>
#include <memory>

class IntArray {
public:
    explicit IntArray(std::size_t n = 0)
        : size_(n), data_(n ? std::make_unique<int[]>(n) : nullptr) {}

    IntArray(const IntArray& other)
        : IntArray(other.size_) {
        std::copy(other.data_.get(), other.data_.get() + size_, data_.get());
    }

    IntArray& operator=(const IntArray& other) {
        if (this == &other) return *this;

        IntArray tmp(other); // 可能失败
        swap(tmp);           // 不抛异常提交
        return *this;
    }

    void swap(IntArray& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }

private:
    std::size_t size_{0};
    std::unique_ptr<int[]> data_;
};

这个例子体现了三层意思:

  • unique_ptr 保证资源不泄漏
  • 临时对象构造阶段负责可能失败的工作
  • swap 完成不抛异常提交

对比总结

概念是什么失败后对象状态是否允许资源泄漏典型适用场景优点代价 / 缺点
基本保证失败后对象仍有效、不变式不被破坏有效但可能改变,通常为未指定状态不允许大多数普通接口、回滚成本高的操作成本较低、容易实现调用方要接受对象状态可能变化
强保证操作要么成功,要么状态完全不变完全不变不允许赋值、容器修改、值语义对象、公共库接口语义清晰,调用方最省心常有额外拷贝、临时对象、内存成本
不抛异常保证操作承诺绝不抛异常正常完成不允许析构、swap、部分移动操作、清理逻辑是其他异常安全策略的基础设计受限,不能乱承诺
RAII用对象生命周期管理资源与状态回滚无直接等价关系通常可避免泄漏所有资源管理场景自动清理,异常路径自然安全不自动提供强保证
copy-and-swap先构造副本,再 swap 提交可实现强保证不允许赋值运算符、自包含资源对象代码稳、逻辑清晰可能有额外拷贝成本
noexcept move移动操作承诺不抛异常便于容器放心移动不允许容器元素类型、自定义值类型提升性能并帮助容器维持异常安全只有确实不抛时才能声明

易错点

  • 把“程序没崩”当成异常安全。 异常安全讨论的是资源、状态、不变式,不是只看是否崩溃。
  • 只会说 try/catch,不会说对象状态恢复。 面试官真正关心的是“失败后对象会怎样”。
  • 把 RAII 和强保证混为一谈。 RAII 解决资源释放,强保证解决状态不变。
  • 认为强保证永远优于基本保证。 实际上强保证常常更贵,工程上要看收益。
  • 误以为“有效但未指定状态”等于“还能正常使用原语义”。 它只保证对象没坏,不保证值不变。
  • 给函数乱加 noexcept。 一旦异常真的逃出 noexcept 函数,会直接 std::terminate
  • 忽视移动构造的 noexcept 对标准库行为的影响。 这不仅关系性能,也关系容器的异常安全策略。
  • 以为所有操作都能做到强保证。 带外部副作用的操作往往做不到完全回滚。
  • 手写回滚代码过多,却没有清晰的提交边界。 更好的思路通常是:临时对象 + 不抛提交
  • 析构函数里抛异常。 这是高风险设计,尤其在栈展开期间。

记忆技巧

  • 三档异常安全一句话:

    • 基本保证:失败了,但对象还活着,资源没丢
    • 强保证:失败了,当作没发生
    • 不抛异常:根本不会抛
  • 一个判断口诀: “资源是否泄漏,看 RAII;状态是否回滚,看强保证;提交是否可靠,看 noexcept。”

  • 强保证实现口诀: “先准备,后提交;提交动作不能抛。”

  • noexcept 的记忆点: “不仅是优化标签,更是容器的决策依据。”

  • 面试高频金句: “RAII 是异常安全的基础,但不等于强异常安全。”

面试速答版

异常安全关注的不是“会不会抛异常”,而是“失败后对象和资源是否仍然正确”。 C++ 里通常有三档:

  • 基本保证:不泄漏资源,对象仍有效,但状态可能改变
  • 强保证:操作要么成功,要么对象状态完全不变
  • 不抛异常保证:操作承诺绝不抛异常

RAII 主要解决资源不泄漏,强保证主要解决状态回滚,两者不是一回事。 强保证的经典做法是 copy-and-swap:先构造临时对象,成功后再用 noexcept swap 提交,所以失败时原对象不变。 标准库很看重移动构造是否 noexcept,因为像 vector 扩容时,只有移动足够安全,容器才敢优先移动并维持更好的异常安全语义。 工程上不一定所有接口都追求强保证,因为它通常有额外拷贝和内存成本,要看收益和场景。

面试加分版

我理解异常安全的重点不是“别抛异常”,而是“抛了以后系统别坏”。在 C++ 里我们既要自己管理资源,又要维护对象语义,所以异常发生时要同时考虑三件事:资源是否泄漏、对象状态是否仍有效、类不变式是否被破坏。

通常分三档。 第一档是基本保证,也就是失败后对象仍然有效、资源不泄漏,但状态可能已经变化,只是仍处于可析构、可继续赋值的状态。 第二档是强保证,也就是操作要么完全成功,要么像没发生过一样,典型思想是 commit-or-rollback。 第三档是不抛异常保证,比如析构函数、swap 这种基础操作,最好承诺不抛,因为它们常常处在清理路径或提交路径上。

这里一个很容易混淆的点是:RAII 和强保证不是一回事。RAII 解决的是异常时资源自动释放,不泄漏;强保证解决的是业务状态是否能回到调用前。比如用智能指针可以确保内存不泄漏,但如果对象成员已经被部分修改,依然不算强保证。

强保证的经典实现是 copy-and-swap。先基于新值构造一个临时对象,这一步可能失败;如果失败,当前对象完全不变。只有当临时对象准备好了,才通过 noexcept swap 一次性提交。这里为什么 swap 经常要求 noexcept,因为它是提交动作,提交如果还能失败,那强保证就不成立了。

再往工程里说,标准库容器非常看重移动构造是否 noexcept。比如 vector 扩容时需要搬迁元素,如果移动可能抛异常,容器为了维持更强的异常安全,可能宁可拷贝也不敢移动;如果移动是 noexcept,它就能更放心地走移动路径,性能和异常安全策略都会更好。

最后,强保证并不总是必须。对于值语义对象、容器、公共库接口,我会尽量争取强保证;但如果操作涉及文件、网络、数据库这种不可逆副作用,或者回滚成本太高,工程上通常只能做到基本保证,或者引入事务、补偿机制。所以异常安全不是单纯背概念,而是根据操作代价、副作用边界和接口契约做设计选择。