24、移动语义与异常安全容器:std::vector 的强异常安全保证、移动操作与容器异常安全、std::deque 的移动
异常安全这个话题,说实话,很多C++开发者写了好几年代码都不太在意。直到线上服务因为一个异常抛出导致数据损坏,才追悔莫及。我早期参与过一个交易系统,就因为vector在扩容时抛了异常,导致部分数据被复制、部分被丢弃——整个订单簿都乱了。从那以后,我对容器的异常安全保证就格外敏感。
今天我们来聊聊移动语义如何影响容器的异常安全。特别是std::vector和std::deque这两个最常用的序列容器。
std::vector 的强异常安全保证
先问一个问题:当你往vector里push_back一个元素时,如果内存重新分配失败了,原来的数据还在吗?
答案是:在C++11之前,这取决于元素类型的拷贝构造函数是否抛出异常。C++11之后,情况变得更有趣了。
vector的强异常安全保证,说白了就是:如果操作失败,容器状态必须和操作前完全一样。不能出现半截数据被搬走、另一半还留在原地的情况。
vector在扩容时,内部逻辑大致是这样的:
// 伪代码,展示vector扩容的核心逻辑
void vector::grow() {
// 1. 分配新内存
auto new_buffer = allocate(new_capacity);
// 2. 尝试将旧元素移动到新内存
// 这里有两种策略
try {
if constexpr (is_nothrow_move_constructible_v<T>) {
// 策略A:移动(安全)
for (auto& elem : old_buffer) {
construct(new_buffer++, std::move(elem));
}
} else {
// 策略B:拷贝(回退)
for (auto& elem : old_buffer) {
construct(new_buffer++, elem);
}
}
} catch (...) {
// 3. 如果失败,销毁已构造的元素,释放新内存
destroy_all(new_buffer);
deallocate(new_buffer);
throw; // 重新抛出异常
}
// 4. 交换内部指针
swap_pointers();
}
这里的关键点在于:如果移动构造函数可能抛出异常,vector会退而使用拷贝构造函数。为什么?因为拷贝操作失败时,旧数据纹丝不动,可以安全回滚。而移动操作一旦成功,旧数据就被"掏空"了,无法回滚。
核心原则:vector只有在元素类型的移动构造函数标记为noexcept时,才会使用移动操作进行扩容。否则,它老老实实用拷贝。
移动操作与容器异常安全
我个人习惯在自定义类型中,只要移动操作不会抛异常,就一定要加上noexcept。这不仅仅是性能优化,更是让容器能安全地使用移动语义。
来看一个实际例子:
#include <vector>
#include <string>
#include <stdexcept>
class SafeType {
std::string data;
public:
// 移动构造函数标记为 noexcept
SafeType(SafeType&& other) noexcept
: data(std::move(other.data)) {}
// 拷贝构造函数可能抛异常
SafeType(const SafeType& other)
: data(other.data) {
if (data.empty()) {
throw std::bad_alloc();
}
}
};
class UnsafeType {
std::string data;
public:
// 移动构造函数没有 noexcept!
UnsafeType(UnsafeType&& other)
: data(std::move(other.data)) {
// 这里可能抛异常吗?不一定,但编译器不知道
}
UnsafeType(const UnsafeType& other)
: data(other.data) {}
};
int main() {
std::vector<SafeType> safe_vec;
safe_vec.reserve(1); // 预留一个元素空间
safe_vec.emplace_back(); // 没问题
// 扩容时,SafeType 的移动是 noexcept
// vector 会使用移动操作,高效且安全
safe_vec.emplace_back(); // 触发扩容
std::vector<UnsafeType> unsafe_vec;
unsafe_vec.reserve(1);
unsafe_vec.emplace_back();
// 扩容时,UnsafeType 的移动没有 noexcept
// vector 会使用拷贝操作,性能较差但安全
unsafe_vec.emplace_back(); // 触发扩容,使用拷贝
}
我曾经在代码审查中看到有人给移动构造函数加了noexcept,但实现里却调用了可能抛异常的函数。这属于自欺欺人——编译器相信你的承诺,但运行时该抛还是抛。一旦真的抛了,程序会直接调用std::terminate,连异常处理的机会都没有。
警告:noexcept不是装饰品。如果你承诺了noexcept但实际抛了异常,程序会直接崩溃。请确保移动操作真的不会抛异常再标记noexcept。
std::deque 的移动
deque和vector在内存布局上完全不同。vector是一块连续内存,而deque是分块存储的——它维护一个"块映射表",每个块是一段连续内存。
这种结构让deque在移动操作上有自己的特点:
#include <deque>
#include <cassert>
struct Heavy {
int id;
Heavy(int i) : id(i) {}
Heavy(Heavy&& other) noexcept : id(other.id) {
other.id = -1; // 源对象被置为"空"状态
}
};
int main() {
std::deque<Heavy> dq1;
dq1.emplace_back(1);
dq1.emplace_back(2);
dq1.emplace_back(3);
// deque的移动构造:直接交换内部指针
std::deque<Heavy> dq2(std::move(dq1));
// dq1现在为空,但它的内存块可能被保留
assert(dq1.empty());
assert(dq2.size() == 3);
// 注意:dq1仍然可以安全使用
dq1.emplace_back(4); // 没问题,dq1重新获得一个块
}
deque的移动操作比vector更"轻量"——它只需要交换几个指针和计数器,不需要逐个移动元素。这是因为deque内部维护的是指向数据块的指针,移动整个容器就是交换这些指针。
小技巧:如果你需要频繁在头部和尾部插入删除,deque比vector更合适。而且deque的移动操作几乎总是O(1)的,不管元素有多少。
但要注意一点:deque在中间插入时,移动操作可能比vector更复杂。因为deque需要调整块映射表,甚至可能分裂块。我在项目中就遇到过在deque中间插入大量元素导致性能骤降的情况。
最后,用一张图来总结本章的核心逻辑:
总结一下:
- vector的强异常安全保证依赖于元素类型的移动操作是否noexcept。是则用移动,否则用拷贝。
- 移动操作与异常安全的关键在于:移动不能抛异常,否则容器无法保证回滚。所以给你的移动操作加上noexcept吧。
- deque的移动天生就是O(1)的指针交换,不受元素类型影响。但中间插入时要注意性能。
嗯,异常安全这个话题其实还有很多细节,比如你自定义的容器该如何实现强异常保证。但今天先到这里,记住一条黄金法则:如果移动操作不会抛异常,一定要标记noexcept。这不仅让容器更高效,也让你的代码更安全。
公众号:蓝海资料掘金营,微信deep3321