19. 为什么移动构造函数和移动赋值运算符通常标记为noexcept?

这个问题,我当年刚接触C++11时也困惑过。移动操作不就是“偷”资源吗?跟异常有什么关系?

后来在一次线上事故中,我才真正理解了——noexcept不是锦上添花,而是移动语义的基石。今天咱们就把它彻底说透。

一、先说结论:noexcept决定了容器是否敢用你的移动操作

标准库容器(比如std::vector)在扩容时,需要把旧元素搬到新内存。它面临一个选择:

  • 用拷贝:安全,但慢(尤其对大对象)
  • 用移动:快,但如果移动操作抛异常,旧数据已经被破坏,无法回滚

标准库的策略很简单:如果移动构造函数是noexcept,就用移动;否则,老老实实拷贝

核心原则:noexcept是移动操作的“安全许可证”。没有它,标准库宁可慢,也不敢用。

二、一个活生生的例子

我写个代码,你一看就明白:

#include <vector>
#include <string>

struct MyType {
    std::string data;
    
    // 移动构造函数——没有noexcept
    MyType(MyType&& other) : data(std::move(other.data)) {
        // 可能抛异常吗?std::string的移动是noexcept的,所以这里不会
        // 但编译器不知道!它只看你的声明
    }
};

int main() {
    std::vector<MyType> v;
    v.reserve(1);  // 先预留1个位置
    v.push_back(MyType{});  // 第一次插入,没问题
    
    v.push_back(MyType{});  // 第二次插入,需要扩容!
    // 这里:因为MyType的移动构造函数没有noexcept
    // vector会选择拷贝!而不是移动!
    return 0;
}

你看,明明移动操作不会抛异常,就因为没写noexcept,vector就选择了拷贝。性能损失可能高达几十倍。

我的习惯:只要我能保证移动操作不抛异常,就一定加上noexcept。这不仅是承诺,更是给标准库的通行证。

三、为什么移动操作通常不会抛异常?

移动操作的本质是“资源窃取”。说白了,就是把指针、句柄这些轻量级的东西从源对象转移到目标对象。

  • 不涉及内存分配:移动不申请新内存,只是转移所有权
  • 不涉及深拷贝:不需要复制大量数据
  • 操作简单:就是几个指针赋值,或者句柄交换

所以,一个设计良好的移动操作,几乎不可能抛异常。我甚至可以说:如果你的移动构造函数抛异常了,那八成是设计出了问题

四、标准库的“强异常安全保证”

这里有个关键概念——强异常安全保证。意思是:如果操作失败,程序状态必须回滚到操作之前的样子。

对于std::vector::push_back来说:

  1. 如果扩容时移动元素,中途抛异常——旧元素已经被移动走了,无法恢复
  2. 如果扩容时拷贝元素,中途抛异常——旧元素还在,可以安全回滚

所以标准库必须保守:除非你明确承诺移动操作不会抛异常,否则我就当它会抛

我曾经踩过的坑:有一次我写了一个自定义容器,移动构造函数里调用了第三方库的接口。那个接口在极端情况下会抛异常。我没加noexcept,结果性能比拷贝还差。后来我重构了设计,把可能抛异常的逻辑移到别处,加上noexcept,性能直接翻倍。

五、什么时候可以不加noexcept?

说实话,场景很少。但确实有:

  • 移动操作确实可能抛异常:比如移动一个包含文件句柄的对象,关闭旧句柄时可能失败
  • 你明确知道这个对象不会被放入标准库容器:比如只用于局部变量传递
  • 你正在实现一个“移动但不安全”的语义:这种情况极少见

但我的建议是:99%的情况下,都加上noexcept。哪怕你只是怀疑,也先加上。如果后来发现真的会抛异常,编译器会告诉你(虽然是以运行时行为异常的方式)。

六、一张图总结核心逻辑

下面这张图,展示了标准库容器在扩容时的决策流程:

容器扩容时:移动还是拷贝? 需要扩容 移动操作 是noexcept? 使用移动 (高效) 使用拷贝 (安全但慢) 扩容完成,元素已安全转移 结论:noexcept 决定容器是否信任你的移动操作

七、实际编码中的最佳实践

说了这么多,落实到代码上其实很简单:

class Buffer {
    int* data_;
    size_t size_;
public:
    // 移动构造函数——必须加noexcept
    Buffer(Buffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    
    // 移动赋值运算符——也必须加noexcept
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }
    
    // 析构函数——默认就是noexcept,不用写
    ~Buffer() {
        delete[] data_;
    }
};

我的建议:写移动操作时,先问自己三个问题:

  1. 这个操作会申请内存吗?——如果会,可能抛异常
  2. 这个操作会调用可能抛异常的函数吗?——如果会,考虑重构
  3. 如果以上都是“否”,果断加noexcept

八、总结

noexcept对于移动操作,就像安全带对于开车。平时你可能觉得它多余,但关键时刻能救命——这里的“命”就是性能。

记住一句话:noexcept不是写给编译器看的,是写给标准库看的。它告诉容器:“放心用我的移动操作,不会出事。”

你想想看,如果你是一个容器,面对两个选择:一个快但可能翻车,一个慢但绝对安全。你会选哪个?

嗯,现在你知道为什么移动操作要加noexcept了。


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