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中间插入大量元素导致性能骤降的情况。

最后,用一张图来总结本章的核心逻辑:

移动语义与容器异常安全决策树 容器需要扩容/移动 元素类型的移动操作是否 noexcept? 使用移动操作 高效、O(1) 或 O(n) 移动 使用拷贝操作 安全但可能较慢 强异常安全保证 失败时完全回滚 强异常安全保证 拷贝失败不影响原数据 注:deque 的移动操作始终是 O(1) 指针交换,不受元素类型影响

总结一下:

  • vector的强异常安全保证依赖于元素类型的移动操作是否noexcept。是则用移动,否则用拷贝。
  • 移动操作与异常安全的关键在于:移动不能抛异常,否则容器无法保证回滚。所以给你的移动操作加上noexcept吧。
  • deque的移动天生就是O(1)的指针交换,不受元素类型影响。但中间插入时要注意性能。

嗯,异常安全这个话题其实还有很多细节,比如你自定义的容器该如何实现强异常保证。但今天先到这里,记住一条黄金法则:如果移动操作不会抛异常,一定要标记noexcept。这不仅让容器更高效,也让你的代码更安全。


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