7、移动语义与异常安全:noexcept 规范在移动操作中的重要性、强异常安全保证的实现策略

异常安全这个话题,说实话,很多C++开发者一开始都不太在意。我早年也是这样,觉得「反正有RAII兜底,出异常就析构呗」。直到有一次,我在一个高并发的交易系统里,因为移动构造函数抛了异常,导致整个容器状态损坏——嗯,从那以后,我再也不敢轻视移动操作中的异常安全问题了。

7.1 移动操作为什么需要 noexcept?

先问一个问题:移动操作到底会不会抛异常?

理论上,移动操作只是「偷资源」——把指针从一个对象拷到另一个对象,然后把源对象置为空。这看起来确实不应该抛异常。但现实是,移动操作可能涉及内存分配(比如std::vector的移动赋值),或者调用用户自定义的移动构造函数,这些都有可能抛出异常。

那问题来了:如果移动操作可能抛异常,标准库容器会怎么处理?

我举个例子。假设你有一个std::vector<MyClass>,当它扩容时,需要把旧元素搬到新内存。如果移动构造函数是noexcept的,vector会放心地使用移动操作。但如果移动构造函数可能抛异常,vector会退而求其次,使用拷贝操作——因为拷贝操作即使抛异常,旧元素还在,不会破坏容器状态。

说白了,noexcept就是告诉编译器:「你放心用移动操作,不会出事。」

核心原则:移动操作应该被标记为 noexcept,除非你有非常充分的理由不这么做。

7.2 noexcept 对标准库行为的影响

我个人习惯,在写移动构造函数和移动赋值运算符时,第一件事就是加上noexcept。为什么?因为标准库的很多优化都依赖这个标记。

来看一个具体的例子:

class MyBuffer {
    char* data_;
    size_t size_;
public:
    // 移动构造函数 —— 必须 noexcept
    MyBuffer(MyBuffer&& other) noexcept
        : data_(other.data_)
        , size_(other.size_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // 移动赋值运算符 —— 也必须 noexcept
    MyBuffer& operator=(MyBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    ~MyBuffer() { delete[] data_; }
};

你想想看,如果我把noexcept去掉,会发生什么?

std::vector<MyBuffer>扩容时,它会先检查std::is_nothrow_move_constructible<MyBuffer>::value。如果结果是false,vector就会改用拷贝操作。拷贝操作可能更慢,而且如果MyBuffer不支持拷贝(比如删除了拷贝构造函数),那vector甚至无法编译通过。

避坑指南:我曾经遇到过一个bug,某个类的移动构造函数忘记加noexcept,结果std::vector在扩容时调用了拷贝构造函数,导致性能骤降。排查了半天才发现是noexcept的问题。

7.3 强异常安全保证的实现策略

强异常安全保证,说白了就是:如果操作抛异常,程序状态必须回滚到操作之前的样子。这个保证在移动语义出现之前,主要靠拷贝+swap来实现。

经典的「copy-and-swap」模式是这样的:

class MyClass {
    std::vector<int> data_;
public:
    // 拷贝赋值运算符 —— 强异常安全保证
    MyClass& operator=(const MyClass& other) {
        // 第一步:拷贝 —— 可能抛异常,但不会修改当前对象
        MyClass temp(other);
        // 第二步:交换 —— noexcept,保证不会失败
        swap(temp);
        return *this;
    }

    void swap(MyClass& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
    }
};

这个模式为什么能保证强异常安全?因为拷贝操作是在临时对象上进行的,即使抛异常,当前对象的状态也不会被破坏。而swap操作是noexcept的,一旦执行就绝不会失败。

但这里有个陷阱:如果MyClass的移动操作是noexcept的,我们可以进一步优化:

MyClass& operator=(MyClass other) noexcept {
    // 这里 other 是传值进来的
    // 如果实参是左值,会拷贝构造 other
    // 如果实参是右值,会移动构造 other
    swap(other);
    return *this;
}

这个写法更简洁,而且自动区分了拷贝和移动。但前提是swap必须是noexcept的,否则赋值运算符就不能标记为noexcept

7.4 知识体系:移动语义与异常安全

下面这张图梳理了本章的核心逻辑:

移动语义与异常安全知识体系 移动操作与异常安全 noexcept 规范 强异常安全保证 标准库优化依赖 vector 扩容策略 copy-and-swap 移动优化版本 std::is_nothrow_* 移动 vs 拷贝选择 临时对象 + swap 传值 + swap 核心原则:移动操作必须 noexcept swap 操作必须 noexcept

7.5 实际项目中的注意事项

我在实际项目中总结了几条经验,分享给你:

  • 移动构造函数和移动赋值运算符必须标记为 noexcept。这是最基础的要求,做不到这一点,标准库容器就不会信任你的移动操作。
  • swap 函数也必须 noexcept。因为 copy-and-swap 模式依赖 swap 的强异常安全保证。
  • 如果移动操作确实可能抛异常(比如涉及内存分配),那就不要标记 noexcept,同时确保拷贝操作可用。这样标准库会退而求其次使用拷贝。
  • 使用 static_assert 来验证 noexcept 特性
static_assert(std::is_nothrow_move_constructible<MyClass>::value,
              "MyClass 必须支持 noexcept 移动构造");
static_assert(std::is_nothrow_swappable<MyClass>::value,
              "MyClass 必须支持 noexcept swap");

小技巧:如果你不确定某个类型是否满足 noexcept 移动构造,可以用 std::is_nothrow_move_constructible_v<T> 在编译期检查。我习惯在单元测试里加上这些断言,一旦有人改了类的定义导致 noexcept 丢失,编译期就能发现。

7.6 一个完整的例子

最后,给你看一个我项目中用过的完整例子。这个类管理一块动态内存,同时保证了强异常安全:

class SafeBuffer {
    char* data_;
    size_t size_;
public:
    // 默认构造函数
    SafeBuffer() noexcept : data_(nullptr), size_(0) {}

    // 移动构造函数 —— noexcept
    SafeBuffer(SafeBuffer&& other) noexcept
        : data_(other.data_), size_(other.size_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // 移动赋值运算符 —— noexcept
    SafeBuffer& operator=(SafeBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    // 拷贝构造函数 —— 可能抛异常,但不会破坏当前对象
    SafeBuffer(const SafeBuffer& other)
        : data_(nullptr), size_(0)
    {
        if (other.size_ > 0) {
            data_ = new char[other.size_];  // 可能抛 bad_alloc
            std::copy(other.data_, other.data_ + other.size_, data_);
            size_ = other.size_;
        }
    }

    // 拷贝赋值运算符 —— 强异常安全保证
    SafeBuffer& operator=(const SafeBuffer& other) {
        SafeBuffer temp(other);  // 拷贝 —— 可能抛异常
        swap(temp);              // 交换 —— noexcept
        return *this;
    }

    // swap —— noexcept
    void swap(SafeBuffer& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
        swap(size_, other.size_);
    }

    ~SafeBuffer() { delete[] data_; }
};

// 验证 noexcept 特性
static_assert(std::is_nothrow_move_constructible<SafeBuffer>::value);
static_assert(std::is_nothrow_swappable<SafeBuffer>::value);

这个例子展示了几个关键点:

  • 移动操作全部 noexcept
  • 拷贝操作可能抛异常,但通过 copy-and-swap 保证了强异常安全
  • swapnoexcept 的,这是整个安全链条的基石

嗯,关于移动语义和异常安全,今天就聊这么多。记住一句话:移动操作要 noexcept,swap 要 noexcept,这是强异常安全保证的基石。下次写移动构造函数时,别忘了加上那个小小的 noexcept——它能让你的代码在标准库容器中跑得更快、更安全。


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