43. noexcept:移动操作与异常安全

移动语义是C++11引入的利器,但很多人忽略了一个关键点——移动操作到底该不该抛异常?

我个人习惯是:能noexcept就noexcept。这不是教条,而是血的教训换来的经验。

为什么移动操作要noexcept?

先看一个场景。你写了个vector,里面存了一堆对象。当vector扩容时,它会怎么做?

嗯,标准做法是:申请新内存,把旧元素搬过去,销毁旧元素。但这里有个选择——用拷贝还是用移动?

如果移动操作可能抛异常,vector会怎么选?它会选拷贝。因为拷贝失败时,旧数据还在,可以回滚。移动如果中途挂了,旧数据已经被破坏,神仙难救。

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

核心原则:如果移动操作不抛异常,一定要标记noexcept。这能让标准库容器选择更高效的移动而非拷贝。

标准库的「信任投票」

我查过C++标准库的实现,比如std::vector的push_back。它的逻辑大致是:

// 伪代码,展示核心逻辑
if constexpr (is_nothrow_move_constructible_v<T>) {
    // 放心用移动
    new (dest) T(std::move(old));
} else {
    // 只能拷贝,保证异常安全
    new (dest) T(old);
}

你看,编译器在编译期就做了决策。如果你的移动构造函数没标noexcept,它就被当成「可能抛异常」处理,从而选择更慢的拷贝路径。

我在项目中遇到过一件事:一个自定义类,移动构造明明不会抛异常,但忘了加noexcept。结果vector扩容时性能骤降,排查了半天才发现是这个问题。

什么时候可以noexcept?

不是所有移动操作都能保证不抛异常。我总结了几种常见情况:

移动操作类型 能否noexcept 说明
基本类型(int, double等) ✅ 可以 纯内存拷贝,不可能抛异常
指针/智能指针 ✅ 可以 只是指针值的转移
std::string ✅ 可以 SSO优化下也是noexcept
std::vector等容器 ✅ 可以 只转移内部指针
自定义类(成员都是noexcept) ✅ 可以 组合起来也是noexcept
自定义类(含动态资源分配) ⚠️ 视情况 如果移动后源对象状态简单,可以noexcept
自定义类(含锁、文件句柄等) ❌ 谨慎 资源转移可能失败

避坑指南:移动后源对象的状态

我曾经犯过一个错误:移动构造函数标了noexcept,但移动后源对象的状态没处理好。

class Buffer {
    int* data_;
    size_t size_;
public:
    Buffer(Buffer&& other) noexcept 
        : data_(other.data_), size_(other.size_)
    {
        // 忘记重置源对象!
        // other.data_ 仍然指向旧内存
        // other.size_ 仍然是旧大小
    }
    
    ~Buffer() { delete[] data_; }
};

问题在哪?源对象析构时会delete掉同一块内存,导致悬空指针。正确的做法是:

Buffer(Buffer&& other) noexcept 
    : data_(other.data_), size_(other.size_)
{
    other.data_ = nullptr;  // 源对象不再拥有资源
    other.size_ = 0;
}

嗯,这里要注意:noexcept只保证不抛异常,但不保证逻辑正确。移动后源对象必须处于「有效但未指定」的状态——可以析构,可以赋值,但不能假设它还有用。

noexcept与异常安全的博弈

你可能会问:如果移动操作确实可能抛异常,硬标noexcept会怎样?

答案是:程序会直接终止。C++规定,如果noexcept函数抛了异常,会调用std::terminate。这不是你想要的。

警告:不要为了性能而谎报noexcept。如果移动操作确实可能抛异常,就别标。否则异常发生时,程序直接崩溃,连catch的机会都没有。

那如果移动操作可能抛异常,怎么办?我一般这么处理:

  • 优先保证移动操作不抛异常。设计类时,让移动操作只转移指针/句柄,不涉及新资源分配。
  • 实在不行就用拷贝。性能差一点,但安全第一。
  • 或者提供强异常保证。比如移动前先备份,失败时回滚。

知识体系图

下面这张图展示了noexcept在移动操作中的核心作用:

noexcept 在移动操作中的决策路径 移动操作 是否 noexcept? (编译期判断) ✅ 使用移动操作 vector扩容、容器操作等选择移动 ❌ 使用拷贝 拷贝保证强异常安全 但性能可能下降 移动性能更优 但必须保证不抛异常

实际项目中的经验

我在做高性能计算库时,所有移动操作都强制要求noexcept。做法很简单:

  1. 移动构造只转移指针,不分配新内存。
  2. 移动赋值先检查自赋值,然后交换指针。
  3. 源对象置为可析构状态,通常是nullptr。

举个例子:

class ImageBuffer {
    uint8_t* pixels_;
    size_t width_, height_;
public:
    // 移动构造:只转移指针,不分配
    ImageBuffer(ImageBuffer&& other) noexcept
        : pixels_(other.pixels_)
        , width_(other.width_)
        , height_(other.height_)
    {
        other.pixels_ = nullptr;
        other.width_ = 0;
        other.height_ = 0;
    }
    
    // 移动赋值:交换指针
    ImageBuffer& operator=(ImageBuffer&& other) noexcept {
        if (this != &other) {
            delete[] pixels_;  // 释放旧资源
            pixels_ = other.pixels_;
            width_ = other.width_;
            height_ = other.height_;
            other.pixels_ = nullptr;
            other.width_ = 0;
            other.height_ = 0;
        }
        return *this;
    }
    
    ~ImageBuffer() {
        delete[] pixels_;
    }
};

小技巧:可以用static_assert在编译期检查你的类是否满足noexcept移动:

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

总结

noexcept对移动操作来说,不是锦上添花,而是雪中送炭。它让标准库敢用移动,让容器敢扩容,让性能能起飞。

但记住:诚实比性能更重要。能noexcept就标,不能就别硬标。设计类时尽量让移动操作简单到不可能失败,这才是正道。

我见过太多人为了那点性能提升,在移动操作里搞复杂逻辑,结果异常安全一塌糊涂。嗯,别学他们。


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