17. 什么是异常安全的移动操作?如何保证移动构造函数不抛异常?

异常安全,说白了就是你的代码在遇到异常时,能不能体面地收场。不会把程序搞崩,也不会让资源泄漏。移动操作里的异常安全,是个特别有意思的话题。

我刚开始接触移动语义时,觉得这玩意儿不就是把指针换了个手吗?能出什么问题?后来在项目里真遇到了,才知道坑有多深。

移动操作为什么可能抛异常?

你想想看,移动操作的本质是「偷资源」。把源对象的资源指针、句柄、文件描述符这些,直接转给目标对象。按理说,这应该是个轻量操作,不该抛异常。

但现实没那么理想。我遇到过的情况包括:

  • 内存分配:有些容器在移动时,需要为新对象分配内部缓冲区
  • 引用计数操作:shared_ptr 移动时,需要原子操作更新计数
  • 自定义资源管理:比如移动一个持有锁的对象,需要重新获取锁

这些操作都可能抛出异常。一旦异常发生,源对象和目标对象的状态就变得不可预测了。

⚠️ 核心问题:如果移动构造函数抛了异常,源对象可能已经被部分修改,目标对象也可能处于半构造状态。程序就陷入了「双重危险」。

异常安全的三个级别

我习惯把异常安全分成三个级别,这样好记:

级别 含义 移动操作中的表现
基本保证 异常发生后,不泄漏资源 移动失败时,源对象处于有效但未指定的状态
强保证 操作要么成功,要么回滚到原始状态 移动失败时,源对象完全不变
不抛异常保证 操作绝对不会抛出异常 移动操作总是成功,这是最理想的情况

对于移动操作,我们追求的是第三级——不抛异常保证。为什么?因为标准库的容器在重新分配内存时,会优先选择不抛异常的移动构造函数。如果移动可能抛异常,容器就会退回到拷贝操作,性能就没了。

如何保证移动构造函数不抛异常?

嗯,这里有几个实用的方法,都是我在项目里验证过的:

1. 使用 noexcept 声明

这是第一步,也是最简单的一步。告诉编译器:我这个移动构造函数不会抛异常。

class MyBuffer {
public:
    MyBuffer(MyBuffer&& other) noexcept
        : data_(other.data_)
        , size_(other.size_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    
private:
    char* data_;
    size_t size_;
};

注意,光写 noexcept 不够,你得确保函数体里真的不会抛异常。

2. 只移动基本类型和 POD 类型

说白了,移动操作里只做指针赋值、整数拷贝这些操作。这些操作在 C++ 里是天生不抛异常的。

class ResourceHolder {
public:
    ResourceHolder(ResourceHolder&& other) noexcept
        : handle_(other.handle_)
        , ref_count_(other.ref_count_)
    {
        other.handle_ = nullptr;
        other.ref_count_ = 0;
    }
    
private:
    void* handle_;      // 指针赋值,不抛异常
    int ref_count_;     // 整数拷贝,不抛异常
};

3. 避免在移动操作中分配内存

我见过有人这么写:

// ❌ 错误示范
class BadExample {
public:
    BadExample(BadExample&& other) {
        // 这里分配内存,可能抛异常!
        buffer_ = new char[other.size_];
        memcpy(buffer_, other.buffer_, other.size_);
        // ...
    }
};

这其实是个深拷贝,不是移动。真正的移动应该直接偷资源,不需要新分配。

4. 使用 std::move_if_noexcept

这个工具函数很有意思。它会在移动构造函数是 noexcept 时执行移动,否则执行拷贝。标准库的容器就靠它来做决策。

template<typename T>
void reallocate(std::vector<T>& vec) {
    // 如果 T 的移动构造函数是 noexcept,就用移动
    // 否则用拷贝
    new_buffer[i] = std::move_if_noexcept(old_buffer[i]);
}

一个完整的例子

我在项目中写过一个简单的字符串类,它的移动操作是这样的:

class FastString {
public:
    // 移动构造函数
    FastString(FastString&& other) noexcept
        : data_(other.data_)
        , length_(other.length_)
        , capacity_(other.capacity_)
    {
        // 把源对象置为空状态
        other.data_ = nullptr;
        other.length_ = 0;
        other.capacity_ = 0;
    }
    
    // 移动赋值运算符
    FastString& operator=(FastString&& other) noexcept {
        if (this != &other) {
            // 先释放自己的资源
            delete[] data_;
            
            // 偷取对方的资源
            data_ = other.data_;
            length_ = other.length_;
            capacity_ = other.capacity_;
            
            // 把源对象置为空
            other.data_ = nullptr;
            other.length_ = 0;
            other.capacity_ = 0;
        }
        return *this;
    }
    
private:
    char* data_;
    size_t length_;
    size_t capacity_;
};

你看,整个移动过程就是指针赋值和整数拷贝,没有任何内存分配或复杂操作。这就是不抛异常的关键。

💡 我的经验:写移动构造函数时,我习惯先列出所有成员变量,然后问自己:这个成员的移动操作会不会抛异常?如果会,能不能改成指针或句柄?实在不行,就考虑用拷贝代替移动。

标准库的约定

标准库对移动操作有个不成文的约定:移动后的源对象应该处于「有效但未指定」的状态。什么意思呢?就是你可以继续使用这个对象,比如给它赋值、析构它,但不能假设它还有原来的值。

我举个例子:

std::string a = "hello";
std::string b = std::move(a);
// 此时 a 是空字符串,但这是实现定义的
// 你不能假设 a 一定是空的,只能说它是有效的

这个约定给了实现者灵活性,也让异常安全更容易保证。因为移动后源对象的状态不需要精确回滚,只要不泄漏资源就行。

SVG:异常安全移动操作决策流程

异常安全移动操作决策流程 设计移动操作 涉及内存分配? 考虑改用拷贝 或重构设计 调用可能抛异常的函数? 改用 noexcept 函数 或提前处理异常 ✅ 声明 noexcept 只做指针赋值和整数拷贝

避坑指南

我曾经在项目里犯过一个错误:移动构造函数里调用了日志函数,结果日志函数在特定配置下会抛异常。那个 bug 查了我整整两天。

所以我的建议是:

  • 移动操作里不要写复杂逻辑。就做资源转移,别干别的。
  • 所有成员变量都应该是可简单移动的。如果某个成员不能简单移动,考虑用指针包装一下。
  • 写单元测试验证 noexcept。用 static_assert 在编译期检查:static_assert(std::is_nothrow_move_constructible_v<MyType>);

总结一下:异常安全的移动操作,核心就一句话——只做不抛异常的事。指针赋值、整数拷贝、句柄转移,这些都可以。内存分配、锁操作、I/O 操作,这些就别放进来。记住这个原则,你的移动操作就能既安全又高效。

专注资料整理