异常安全与STL:异常安全级别、STL容器的异常保证、noexcept规范、实战:事务性数据库操作
异常安全这个话题,说实话,很多C++开发者写了三五年代码都不一定真正重视过。我早年在一家做交易系统的公司干活时,就吃过这个亏——一个异常导致数据库状态半死不活,数据直接对不上了。那次事故之后,我才真正把异常安全当成一门必修课来研究。
今天咱们就把这块彻底聊透。从异常安全的三个级别开始,再到STL容器各自给了什么承诺,最后用noexcept规范收尾,再做一个事务性数据库操作的实战。
一、异常安全的三个级别
异常安全不是玄学,它有一套明确的衡量标准。业内公认的是Abrahams提出的三级分类:
| 级别 | 含义 | 通俗理解 |
|---|---|---|
| 基本保证 | 抛出异常后,不泄漏资源,对象处于有效但不确定的状态 | 程序不会崩,但数据可能变了样 |
| 强保证 | 操作要么完全成功,要么回滚到操作前的状态 | 像数据库事务一样,要么全做,要么不做 |
| 不抛出保证 | 承诺操作绝对不会抛出异常 | 最安全,但也最难做到 |
我个人习惯把这三个级别记成:基本保证是底线,强保证是追求,不抛出是理想。你想想看,如果你的函数连基本保证都做不到,那一旦异常发生,资源泄漏、内存泄漏、句柄泄漏全来了,这代码基本没法在生产环境跑。
核心原则:写代码时,至少保证每个函数都达到基本保证。能提供强保证更好,但别为了强保证把性能搞崩了。
二、STL容器的异常保证
STL容器在这方面做得相当讲究。我当年刚接触STL时,以为所有容器都一样,后来踩了坑才发现——不同容器给的保证完全不同。
2.1 vector的异常保证
vector的push_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的异常保证
链表容器是异常安全的优等生。因为它们的操作不涉及内存重新分配,每个节点独立分配。insert、push_back、push_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;
}
这个模式的核心就一句话:先拷贝,再修改,最后交换。只要swap是noexcept的(vector的swap确实是),那整个操作就是强保证的。
关键点:std::vector::swap是noexcept的,所以交换操作绝对不会失败。这就是事务性操作的基石。
我在实际项目中,把这个模式用在了配置管理模块里。每次批量更新配置时,先在新副本上做所有校验和修改,校验通过后再swap。这样即使中间抛异常,线上配置也不会被污染。说白了,这就是用异常安全来保证数据一致性。
五、知识体系总览
下面这张图把本章的核心逻辑串起来了,你可以对照着回顾一下:
异常安全不是锦上添花,它是生产级代码的硬性要求。我见过太多线上事故,起因就是一个异常把状态搞乱了。记住:写代码时多花十分钟考虑异常安全,上线后能省十个小时的排查时间。