CodeGym /课程 /C++ SELF /异常安全保证:basic / strong / no‑throw

异常安全保证:basic / strong / no‑throw

C++ SELF
第 54 级, 课程 1
可用

1. 为什么需要异常安全保证

如果你刚开始编程,可能认为异常只是“报告错误的一种方式”。实际上它也是对你设计成熟度的考验:对象能否度过“糟糕的日子”。异常安全保证是方法对在异常发生后对象状态所作的形式化承诺,而不仅仅是对资源的承诺。

想象你的对象是一个小办公室。RAII 保证在恐慌过后员工不会忘记关掉熨斗或把钥匙落在门里(资源不会泄漏)。但办公室可能一片狼藉:文件散落一地,一半文件被重命名,另一半没有。异常安全保证就是回答这个问题:在混乱之后,我们至少保证文件柜里的文件有序吗?

为了把主题落到实处,我们继续用一个假想的教学应用:TaskTracker —— 一个内存中的简单任务管理器,保存任务、标签和快速索引。

方法契约与保证的类型

当你调用一个方法时,通常期望两件事:要么方法完成工作,要么诚实地报告失败。当方法失败但已经“做了点事”——对象处于中间状态时就会出现问题。这正是契约表述比“代码好看”更重要的地方。

在异常语境下,契约通常这样表述:“如果方法抛出异常,那么关于对象状态的哪些断言仍然成立?”这些断言就称为异常安全保证。重要的是理解,保证不是哲学或道德,而是极其实用的东西:在设计其余代码时可以依赖的规则。

下面我们将使用术语 basic guarantee、strong guarantee、no‑throw guarantee。这些术语在讨论标准库和容器设计时经常出现,因为没有它们就无法解释为什么某些操作“更安全”。

保证表:basic vs strong vs no‑throw

为了不在定义中迷失,我们用一张表来固定含义。这是少见的确实能减轻痛苦的情况。

保证 异常时的承诺 可能发生的变化 “付出”是什么
Basic guarantee 对象保持有效,不变式未被破坏,资源不泄漏 状态可能部分改变(但仍然正确) 通常几乎不需要额外代价:只需小心维护不变式
Strong guarantee “要么全部成功,要么状态没有改变” 状态不变(相对于“之前”) 通常需要在临时状态中准备,有时在时间/内存上更昂贵
No‑throw guarantee 方法不会向外抛出异常 状态不重要(但通常仍然要求正确性) 需要限制方法的行为,或在内部捕获/处理错误

一个小事实以示成熟度:即便在标准库的讨论中也会出现类似“在没有 no‑throw guarantee 的情况下 swap 规范会矛盾”的表述——也就是说“不抛出”的保证会影响操作的要求及其正确性。

现在我们分别用简单的语言和短小的示例来讲解每种保证。

2. Basic guarantee:对象还活着,但可能“搬了家具”

Basic guarantee 是异常世界中的最低合理级别。它表明:“如果操作因为异常失败,对象仍然可以继续使用和销毁,不变式得到保持,但部分状态可能已经改变”。听起来谦逊,但在实践中这大大胜过混乱。

关键字是有效。有效对象不一定“和以前一样”。它只是没有被破坏:其方法依然可以调用,析构可以正常执行,并且内部没有自相矛盾的状态。

没有保证的示例:破坏不变式

我们做一个任务存储,它有一个不变式:index_by_id_ 必须与 tasks_ 相对应。如果任务存在于 tasks_ 中,那么通过它的 id 应该能找到它的索引。

#include <string>
#include <unordered_map>
#include <vector>

struct Task {
    int id;
    std::string title;
};

class TaskStorage {
public:
    void add_task_no_guarantee(int id, std::string title) {
        tasks_.push_back(Task{id, std::move(title)});     // 可能抛出
        index_by_id_.emplace(id, tasks_.size() - 1);      // 可能抛出
    }

private:
    std::vector<Task> tasks_;
    std::unordered_map<int, std::size_t> index_by_id_;
};

这里的问题不在于“可能抛出”。问题在于如果 emplace 抛出异常,tasks_ 已经被修改,而 index_by_id_ 没有。结果不变式被破坏。这甚至不是 basic guarantee——这是“祝你好运”的保证。

具有 basic guarantee 的示例:回滚已完成的操作

Basic guarantee 要求在异常之后对象保持有效。因此,如果我们修改了一部分状态但无法修改另一部分,就需要将已做的修改恢复到一致状态。

#include <string>
#include <unordered_map>
#include <vector>

struct Task {
    int id;
    std::string title;
};

class TaskStorage {
public:
    void add_task_basic(int id, std::string title) {
        tasks_.push_back(Task{id, std::move(title)}); // 步骤 1
        try {
            index_by_id_.emplace(id, tasks_.size() - 1);  // 步骤 2
        } catch (...) {
            tasks_.pop_back();                            // 回滚(不抛出)
            throw;                                        // 继续抛出
        }
    }

private:
    std::vector<Task> tasks_;
    std::unordered_map<int, std::size_t> index_by_id_;
};

注意一点“教学式”的想法:我们并不是“修复”异常。我们只是确保对象不会处于损坏状态。这就是基本的异常安全:不隐藏问题,但保持状态正确性。

4. Strong guarantee:“要么全部成功,要么什么都没发生”

Strong guarantee 听起来像完美主义者的梦想:“如果操作未完成,对象状态与调用前完全相同”。在现实世界中这并不总是廉价可得。但这是一个非常强大的保证,因为调用者可以更简单地思考:如果抛出异常——就像根本没有开始过一样。

何时 strong guarantee 有用?当“半成品”在逻辑上是危险的。例如,你同时更新多个相关数据结构,如果只更新了一半,后续程序会基于错误信息做出不正确的决定。

在小示例中实现 strong guarantee:先验证,然后修改

有时 strong guarantee 可以通过一个简单的手段实现:在修改状态之前完成所有检查。

#include <stdexcept>
#include <string>

class UserName {
public:
    void set(std::string n) {
        if (n.empty()) {
            throw std::invalid_argument("empty name");
        }
        name_ = std::move(n); // 即便这里抛出(罕见),旧的名字应当被保留
    }

    const std::string& get() const { return name_; }

private:
    std::string name_{"unknown"};
};

这里我们达成了一个重要效果:非法输入 不会导致状态改变。在输入验证方面这已经优于 basic。

对“复合状态”实现 strong guarantee:标签 + 计数器

我们在 TaskStorage 中再加一个不变式。假设有一个 tag_usage_:记录每个标签在所有任务中出现的次数。不变式要求计数之和/各计数应与标签的实际内容一致。

糟糕的实现(又是“无保证”):

#include <string>
#include <unordered_map>
#include <vector>

struct Task {
    int id;
    std::vector<std::string> tags;
};

class TaskStorage {
public:
    void add_tag_no_guarantee(std::size_t i, std::string tag) {
        tasks_[i].tags.push_back(tag);          // 可能抛出
        tag_usage_[tag] += 1;                   // 可能抛出(插入/rehash)
    }

private:
    std::vector<Task> tasks_;
    std::unordered_map<std::string, int> tag_usage_;
};

如果 tag_usage_ 在成功执行 push_back 之后抛出异常,那么我们会有标签存在但计数不存在的情况。不变式被破坏。

现在给出一种实现,它用最少的逻辑实现 strong 行为:如果第二步失败——回滚第一步。要点是:回滚本身必须是“不会失败”的,否则我们会在处理异常时抛出第二个异常(那将是另一个地狱循环)。

#include <string>
#include <unordered_map>
#include <vector>

struct Task {
    std::vector<std::string> tags;
};

class TaskStorage {
public:
    void add_tag_strong(std::size_t i, const std::string& tag) {
        tasks_[i].tags.push_back(tag);          // 步骤 1
        try {
            tag_usage_[tag] += 1;               // 步骤 2
        } catch (...) {
            tasks_[i].tags.pop_back();          // 回滚步骤 1
            throw;
        }
    }

private:
    std::vector<Task> tasks_;
    std::unordered_map<std::string, int> tag_usage_;
};

形式上我们达成了“要么添加了标签并增加计数,要么什么都没做”。对于我们的不变式而言,这是该操作的 strong guarantee。

是的,这看起来很像 commit/rollback。在下一讲(278)我们会讲更系统且可扩展的方法(特别是当步骤多于两步时),但现在我们关注的是保证的逻辑:我们承诺什么。

5. No‑throw guarantee:“不向外抛出异常”

No‑throw guarantee 不是“最强的保证”,而是另一种维度。它回答的问题不是“会剩下什么状态”,而是“函数边界之外会不会有异常”。有时这是至关重要的:例如在析构函数中(那里几乎总不能向外抛出异常)、在释放资源的函数中、在某些底层操作中。

心理上 no‑throw 很狡猾:初学者希望“所有东西都 no‑throw,这样就不用考虑了”。但现实是:如果操作可能失败(分配内存、读取文件、解析数字),要承诺“不抛”只有在你愿意在函数内部用其他方式处理错误时才行。

自然的 no‑throw 候选:简单的状态查询

Getter 和状态检查通常很容易实现为不会抛出的,因为它们不做危险的事情。

#include <vector>

class TaskStorage {
public:
    std::size_t task_count() const noexcept {   // 关于 noexcept 稍后详述
        return tasks_.size();
    }

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

这里没有内存分配、没有复杂逻辑、没有解析。因此把它作为“不抛出”是合理的目标。

不太适合 no‑throw 的候选:构造字符串

经典陷阱是:想做漂亮的 to_string() 并承诺“不抛”,但内部会创建字符串、连接、格式化——这些都可能需要内存。

在这种情况下,更诚实的做法要么是不承诺 no‑throw,要么返回一个可能为空的结果(但那是另一种错误处理策略,并非直接属于异常安全的讨论范畴)。

6. 如何为方法选择保证:实用视角

在教科书里很容易写“总是提供 strong guarantee”。在真实代码中你会很快发现这有时既昂贵又不便。因此更有用的思路是:调用方需要哪种保证,才能让程序保持简单且可靠?

对于我们的 TaskTracker,可以采用如下策略(不是板上钉钉的规则,而是常识性建议):

方法 做什么 通常合理的保证
task_count()
只是读取数量 no‑throw(理所当然)
find_task(id)
查找任务 no‑throw 或 basic(如果不更改状态)
add_task(...)
添加任务 + 索引 至少 basic,通常也可方便地实现 strong
add_tag(...)
修改任务 + 计数器 最好是 strong,否则很容易出现不同步
load_from_text(...)
解析输入/文件 可能抛出,保证取决于设计(通常通过“先在临时处构造”实现 strong)

这里有一个重要点:strong guarantee 往往不是为了“好看”,而是为保证不变式的一致性。如果对象内部保持多个关联结构,那么部分更新就是个地雷。你可以名义上声称“basic guarantee”,但如果异常后结构变得不一致——那不是 basic,那是“什么都没有”。

7. 小演示:如何手动观察保证

读定义时一切看起来都清楚。但大脑真正记住的是你亲眼看到效果的那一刻:例如“在这里抛出异常——对象仍然正常”或“在这里抛出异常——对象变成南瓜”。为此需要能在操作中间故意触发异常。

最简单的教学技巧是:写一个“有时会抛出”的函数。在生产中不要这样做,但用于演示非常合适。

#include <stdexcept>

void fail_if(bool condition) {
    if (condition) {
        throw std::runtime_error("simulated failure");
    }
}

然后在操作内部插入 fail_if(true) 在需要的位置,观察:不变式是否保持,是否还能继续工作,对象是否崩溃。

例如,伪造性地破坏索引添加:

#include <string>
#include <unordered_map>
#include <vector>

struct Task { int id; std::string title; };

class TaskStorage {
public:
    void add_task_demo(int id, std::string title, bool fail_on_index) {
        tasks_.push_back(Task{id, std::move(title)});
        fail_if(fail_on_index);                 // 在“步骤之间”模拟故障
        index_by_id_.emplace(id, tasks_.size() - 1);
    }

private:
    std::vector<Task> tasks_;
    std::unordered_map<int, std::size_t> index_by_id_;
};

如果没有回滚,那么在 fail_if(true) 之后对象就已经被半途修改。这正是让你反思:“我到底打算提供什么保证?我真的实现了吗?”

8. 选择异常安全保证时的典型错误

错误①:认为 RAII 自动为一切提供 strong guarantee。
RAII 确实在很多情况下神奇地保护资源,但它并不会修复对象的逻辑状态。如果你修改了三个字段,而在第四个字段出错——可能不会泄漏资源,但对象可能处于“半更新”状态。保证关注的是状态,而非内存。

错误②:声称 basic guarantee,但在异常时破坏不变式。
Basic guarantee 要求对象有效。如果你的对象维护关联结构(例如 vector + unordered_map 索引),部分更新会使对象无效。在这种情况下你并没有“提供 basic”,而是没有任何保证。需要回滚更改,或改变操作顺序以避免破坏不变式。

错误③:混淆 strong guarantee 与 no‑throw guarantee。
Strong guarantee 表示“如果操作失败,状态不变”,但操作本身依然可能抛出异常。No‑throw 表示“不向外抛出异常”,但这不意味着状态不变或操作一定成功。它们是不同维度的承诺,混淆会导致非常奇怪的接口设计。

错误④:把 no‑throw 作为默认目标。
有时人们想声明“我们的方法都 no‑throw”。但如果内部有内存分配、解析或 I/O,要么你掩盖错误(日后会出现难以查找的 bug),要么你要构建替代的错误处理体系。No‑throw 是有用的工具,但只应在诚实的场合使用。

错误⑤:进行“肉眼”回滚,忽略回滚自身也必须是安全的。
如果你在 catch 中用可能抛出异常的操作去回滚修改,你就有可能在处理第一个异常时抛出第二个异常。因此回滚应尽可能简单且可靠:如 pop_back()、将计数器减回 1、用已准备好的状态做 swap 等。我们将在下一讲更系统地讨论 commit/rollback 以及把 swap 当作提交步骤的用法。

1
任务
C++ SELF, 第 54 级, 课程 1
已锁定
昵称无风险
昵称无风险
1
任务
C++ SELF, 第 54 级, 课程 1
已锁定
故障统计
故障统计
1
任务
C++ SELF, 第 54 级, 课程 1
已锁定
任务存储
任务存储
1
任务
C++ SELF, 第 54 级, 课程 1
已锁定
不会崩溃的解析器
不会崩溃的解析器
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION