7. 移动语义与异常安全:noexcept 的重要性,std::vector 的移动优化策略

好,咱们今天聊一个实战中特别容易踩坑的话题——移动语义和异常安全

说实话,我刚学移动语义那会儿,觉得这东西挺简单的:不就是把资源“偷”过来嘛,省一次拷贝。但后来在项目里被坑了几次,才发现事情没那么简单。尤其是跟 std::vector 这种容器打交道时,一个 noexcept 写没写对,性能可能差出几个数量级。

为什么会这样?

嗯,咱们一步步拆开看。

7.1 异常安全的基本概念

先聊两句异常安全。C++ 里有个基本承诺:操作要么成功,要么回滚到操作前的状态。这叫“强异常安全保证”。

移动语义出现后,这个承诺变得微妙了。你想想看:

  • 拷贝操作:失败了无所谓,原对象还在。
  • 移动操作:资源已经“偷”走了,原对象被掏空了。这时候如果后续操作抛异常,怎么回滚?

说白了,移动操作一旦失败,原对象的状态就不可控了。这就是异常安全在移动语义里的核心矛盾。

核心原则:移动操作如果可能抛异常,容器就不敢用它做“强安全保证”的操作。

7.2 noexcept 的作用

noexcept 这个关键字,说白了就是给编译器一个承诺:“我这个函数绝对不会抛异常。”

对于移动构造函数和移动赋值运算符,这个承诺特别重要。我个人的习惯是:只要你能保证移动操作不抛异常,就一定要加上 noexcept

来看个例子:

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

    // 拷贝构造函数 —— 可能抛异常(内存分配失败)
    MyBuffer(const MyBuffer& other)
        : data_(new char[other.size_]), size_(other.size_)
    {
        memcpy(data_, other.data_, size_);
    }
};

注意看:移动构造只是交换指针,没有内存分配,所以 noexcept 是安全的。拷贝构造要 new,可能抛 std::bad_alloc,所以不能加 noexcept

小技巧:如果你不确定移动操作是否真的不抛异常,可以用 static_assert 来验证:

static_assert(std::is_nothrow_move_constructible_v<MyBuffer>,
              "MyBuffer 的移动构造必须 noexcept!");

7.3 std::vector 的移动优化策略

好,重点来了。std::vector 在扩容时,会面临一个选择:

  • 拷贝元素:安全,但慢。
  • 移动元素:快,但可能抛异常。

标准库是怎么选的?

答案是:看你的移动操作是不是 noexcept

具体来说,std::vector 扩容时内部会调用 std::move_if_noexcept。这个函数的名字已经说得很明白了:

  • 如果移动构造函数是 noexcept,就用移动。
  • 否则,退化为拷贝。

为什么?因为 vector 要保证强异常安全。如果移动过程中抛异常,已经移动过的元素状态就乱了,没法回滚。拷贝则不同——拷贝失败了,原数组纹丝不动。

我举个例子你就明白了:

#include <vector>
#include <string>

struct SafeMove {
    std::string data;
    SafeMove(SafeMove&&) noexcept = default;
};

struct UnsafeMove {
    std::string data;
    UnsafeMove(UnsafeMove&&) /* 没有 noexcept */ = default;
};

int main() {
    std::vector<SafeMove> v1;
    v1.reserve(1);
    v1.emplace_back();  // 扩容时用移动 —— 快

    std::vector<UnsafeMove> v2;
    v2.reserve(1);
    v2.emplace_back();  // 扩容时用拷贝 —— 慢
}

你看,SafeMoveUnsafeMove 内部都是 std::string,但一个加了 noexcept,一个没加。结果扩容时的行为完全不同。

我曾经踩过的坑

有一次我写了一个自定义类型,内部用 std::unique_ptr 管理资源。移动构造我写了,但忘了加 noexcept。结果 std::vector 扩容时全部走拷贝路径,性能直接崩了。排查了半天才发现是 noexcept 的问题。

从那以后,我给自己定了个规矩:所有移动构造函数和移动赋值运算符,默认都加 noexcept,除非你明确知道它会抛异常。

7.4 知识体系总览

下面这张图帮你理清本章的核心逻辑:

移动语义与异常安全 · 核心逻辑 移动操作 noexcept 非 noexcept vector 使用移动 性能高 vector 退化为拷贝 性能低 核心结论 移动操作加 noexcept → vector 用移动 → 高性能

7.5 实战建议

最后,给你几条我实战中总结出来的经验:

  1. 默认加 noexcept:移动构造、移动赋值、swap,只要你能保证不抛异常,就加上。这是最省心的做法。
  2. static_assert 做编译期检查:尤其是模板代码里,确保你的类型满足 is_nothrow_move_constructible
  3. 注意 std::pairstd::tuple:它们的移动构造是否 noexcept,取决于内部所有成员。一个成员没加,整个就退化了。
  4. 别迷信 std::move:它只是类型转换,真正决定性能的是目标类型有没有 noexcept 的移动构造。

一句话总结noexcept 不是可有可无的装饰,它直接决定了 std::vector 是走“快车道”还是“慢车道”。

好了,这一章就聊到这儿。记住:写移动操作时,顺手加个 noexcept,这是成本最低、收益最高的优化之一。


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