异常安全与STL:异常安全级别、STL容器的异常保证、noexcept规范、实战:事务性数据库操作

异常安全这个话题,说实话,很多C++开发者写了三五年代码都不一定真正重视过。我早年在一家做交易系统的公司干活时,就吃过这个亏——一个异常导致数据库状态半死不活,数据直接对不上了。那次事故之后,我才真正把异常安全当成一门必修课来研究。

今天咱们就把这块彻底聊透。从异常安全的三个级别开始,再到STL容器各自给了什么承诺,最后用noexcept规范收尾,再做一个事务性数据库操作的实战。

一、异常安全的三个级别

异常安全不是玄学,它有一套明确的衡量标准。业内公认的是Abrahams提出的三级分类:

级别 含义 通俗理解
基本保证 抛出异常后,不泄漏资源,对象处于有效但不确定的状态 程序不会崩,但数据可能变了样
强保证 操作要么完全成功,要么回滚到操作前的状态 像数据库事务一样,要么全做,要么不做
不抛出保证 承诺操作绝对不会抛出异常 最安全,但也最难做到

我个人习惯把这三个级别记成:基本保证是底线,强保证是追求,不抛出是理想。你想想看,如果你的函数连基本保证都做不到,那一旦异常发生,资源泄漏、内存泄漏、句柄泄漏全来了,这代码基本没法在生产环境跑。

核心原则:写代码时,至少保证每个函数都达到基本保证。能提供强保证更好,但别为了强保证把性能搞崩了。

二、STL容器的异常保证

STL容器在这方面做得相当讲究。我当年刚接触STL时,以为所有容器都一样,后来踩了坑才发现——不同容器给的保证完全不同

2.1 vector的异常保证

vectorpush_back在扩容时如果内存分配失败,会抛出std::bad_alloc。但STL保证:如果push_back失败,vector保持原状。这就是强保证。

insert操作呢?如果插入位置不是末尾,需要移动元素。移动操作如果抛异常,那vector只能提供基本保证——元素可能被移动了一半,状态不确定。

std::vector<std::string> v = {"a", "b", "c"};
try {
    v.insert(v.begin() + 1, "x");  // 如果移动抛异常,v状态不确定
} catch (...) {
    // 这里v可能已经乱了
}

注意:我曾经在项目中遇到过一个坑——自定义类型的移动构造函数没有标记noexcept,结果vector在扩容时选择了拷贝而不是移动,性能直接掉了一个数量级。后来排查了半天才发现是这个问题。

2.2 list和forward_list的异常保证

链表容器是异常安全的优等生。因为它们的操作不涉及内存重新分配,每个节点独立分配。insertpush_backpush_front都提供强保证

为什么?因为链表插入只需要改几个指针,如果分配新节点失败,旧节点纹丝不动。说白了,链表天生就是事务性的。

2.3 map/set的异常保证

关联容器也基本提供强保证。插入一个元素时,如果比较操作或分配内存失败,容器状态不变。但要注意:如果插入的元素已经存在,insert不会失败,只是什么都不做

2.4 unordered容器的异常保证

哈希容器稍微复杂一点。单个元素的插入是强保证,但批量插入(如范围插入)只提供基本保证。因为哈希表在rehash时如果失败,部分元素可能已经插入了。

容器 单元素插入 范围插入 erase
vector 强保证(末尾) 基本保证 不抛出
list 强保证 强保证 不抛出
map/set 强保证 基本保证 不抛出
unordered_map 强保证 基本保证 不抛出

三、noexcept规范

noexcept是C++11引入的关键字,用来告诉编译器:这个函数不会抛异常。它不只是文档,更是优化开关。

我举个例子你就明白了:

void process_data() noexcept {
    // 如果这里抛了异常,程序直接terminate
}

为什么要有noexcept?因为编译器知道函数不会抛异常后,可以做一些激进优化——比如省掉异常处理栈展开的代码。更重要的是,STL的很多操作会检查你的类型是否noexcept可移动

实战建议:移动构造函数和移动赋值运算符,尽量标记为noexcept。这样vector在扩容时就会用移动而不是拷贝,性能提升非常明显。我自己的习惯是:只要确定不会抛异常,就加上noexcept

但也要注意:noexcept不是银弹。如果一个函数内部调用了可能抛异常的函数,你却强行标记noexcept,那异常一旦发生,程序直接崩溃。嗯,这里要谨慎。

四、实战:事务性数据库操作

好了,理论说完了,咱们来点真格的。假设你正在写一个数据库操作类,需要保证一组操作要么全部成功,要么全部回滚。这就是典型的事务场景。

我用STL的vector来模拟数据存储,用异常安全机制来实现事务:

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

class Database {
    std::vector<std::string> records_;
    
public:
    // 事务性批量插入:强保证
    void transactional_insert(const std::vector<std::string>& items) {
        // 先做所有检查,不修改状态
        for (const auto& item : items) {
            if (item.empty()) {
                throw std::invalid_argument("空记录不允许");
            }
        }
        
        // 创建临时副本,所有修改都在副本上进行
        auto temp = records_;
        temp.reserve(temp.size() + items.size());  // 可能抛异常
        
        for (const auto& item : items) {
            temp.push_back(item);  // 如果抛异常,temp析构,原数据不变
        }
        
        // 全部成功,才交换
        // swap 是 noexcept 的
        records_.swap(temp);
    }
    
    // 查询
    void print() const {
        for (const auto& r : records_) {
            std::cout << r << " ";
        }
        std::cout << "\n";
    }
};

int main() {
    Database db;
    db.transactional_insert({"a", "b", "c"});
    db.print();  // 输出: a b c
    
    try {
        db.transactional_insert({"d", "", "e"});  // 空记录触发异常
    } catch (const std::exception& e) {
        std::cout << "事务失败: " << e.what() << "\n";
    }
    
    db.print();  // 输出: a b c  (数据没变!)
    
    return 0;
}

这个模式的核心就一句话:先拷贝,再修改,最后交换。只要swapnoexcept的(vector的swap确实是),那整个操作就是强保证的。

关键点std::vector::swapnoexcept的,所以交换操作绝对不会失败。这就是事务性操作的基石。

我在实际项目中,把这个模式用在了配置管理模块里。每次批量更新配置时,先在新副本上做所有校验和修改,校验通过后再swap。这样即使中间抛异常,线上配置也不会被污染。说白了,这就是用异常安全来保证数据一致性。

五、知识体系总览

下面这张图把本章的核心逻辑串起来了,你可以对照着回顾一下:

异常安全与STL 知识体系 异常安全三级别 STL容器保证 noexcept规范 基本保证 · 强保证 · 不抛出 资源不泄漏 · 回滚 · 绝对安全 vector · list · map · unordered 强保证 vs 基本保证 对比 优化开关 · 移动语义 swap必须noexcept 实战:事务性数据库操作 ① 先校验 ② 副本修改 ③ noexcept交换

异常安全不是锦上添花,它是生产级代码的硬性要求。我见过太多线上事故,起因就是一个异常把状态搞乱了。记住:写代码时多花十分钟考虑异常安全,上线后能省十个小时的排查时间


公众号:蓝海资料掘金营,微信deep3321