4. 移动构造函数:定义与实现,从源对象“偷”资源,避免深拷贝

好,咱们进入移动语义的核心地带了。

前面我们聊了右值引用,说白了它就是个「身份标记」——告诉编译器:这个对象是临时的,你可以随便折腾它,不用客气。那移动构造函数,就是把这个标记真正用起来的地方。

4.1 为什么要移动?深拷贝的痛

先想想看,一个传统的拷贝构造函数在干什么?

class MyString {
public:
    MyString(const char* s) {
        size_ = strlen(s);
        data_ = new char[size_ + 1];
        memcpy(data_, s, size_ + 1);
    }

    // 拷贝构造函数:老老实实深拷贝
    MyString(const MyString& other) {
        size_ = other.size_;
        data_ = new char[size_ + 1];   // 重新分配内存
        memcpy(data_, other.data_, size_ + 1);  // 复制数据
        std::cout << "深拷贝,好累...\n";
    }

    ~MyString() { delete[] data_; }

private:
    char* data_;
    size_t size_;
};

这段代码有什么问题?

嗯,问题大了去了。每次拷贝都要 new 一块新内存,再把数据一个一个复制过去。如果字符串有 10MB,那就得老老实实复制 10MB。我当年在做一个日志处理系统时,就因为这个深拷贝,程序跑起来 CPU 直接飙到 100%,内存分配器都快哭了。

但你想,如果源对象是个临时对象——比如函数返回的局部字符串——它马上就要被销毁了。我们为什么还要费劲去复制它的数据?直接把它的指针「偷」过来不就行了?

核心思想: 移动构造函数不是「复制」资源,而是「转移」资源的所有权。

4.2 移动构造函数的定义

语法其实很简单:参数是「右值引用」,也就是 T&&

class MyString {
public:
    // 移动构造函数
    MyString(MyString&& other) noexcept
        : data_(other.data_)    // 偷指针,不重新分配
        , size_(other.size_)
    {
        // 把源对象置为空,防止析构时释放我们的数据
        other.data_ = nullptr;
        other.size_ = 0;
        std::cout << "移动构造,轻松偷资源!\n";
    }

    // ... 其他成员函数不变
};

看到区别了吗?

  • 参数类型: MyString&& other —— 只接受右值
  • 操作: 直接 data_ = other.data_,没有 new,没有 memcpy
  • 关键一步:other.data_ 置为 nullptr
⚠️ 千万注意: 移动后必须把源对象置为「空状态」!否则源对象析构时会 delete[] 掉我们刚偷来的数据,那就全完了。

我曾经就犯过这个错。有一次写一个网络缓冲区类,移动构造函数里忘了把源对象的指针置空。结果程序跑着跑着,缓冲区里的数据莫名其妙就没了。调试了两天才发现,原来是源对象析构时把数据释放了。嗯,从那以后我写移动构造函数,第一件事就是检查「源对象是否被清空」。

4.3 什么时候会调用移动构造函数?

说白了,就是当右值被用来构造一个新对象时:

MyString s1("Hello, World!");

// 场景1:用临时对象构造
MyString s2(MyString("Temporary"));  // 调用移动构造

// 场景2:std::move 强制转换
MyString s3(std::move(s1));          // 调用移动构造
// 注意:s1 现在处于「有效但未指定」状态,不要再用了

你可能会问:为什么 std::move(s1) 也能触发移动构造?

因为 std::move 本质上就是个类型转换,它把左值 s1 转成了右值引用。编译器一看参数是右值引用,就匹配到移动构造函数了。

💡 个人习惯: 我一般在函数返回局部对象时,不会手动写 std::move。因为编译器有 RVO(返回值优化),它会自动选择移动构造甚至直接构造。你手动 std::move 反而可能抑制优化。

4.4 移动 vs 拷贝:性能对比

咱们来做个简单的对比:

操作 拷贝构造函数 移动构造函数
内存分配 需要 new 新内存 不需要,直接偷指针
数据复制 O(n) 逐字节复制 O(1) 指针赋值
源对象状态 保持不变 被置为空
异常安全 可能抛出 bad_alloc 通常 noexcept
适用场景 需要保留源对象 源对象即将销毁

你看,移动构造几乎就是「零成本」操作。对于像 std::vectorstd::string 这样的容器,移动构造只是交换几个指针和大小值,完全不需要遍历元素。

4.5 noexcept 的重要性

你可能注意到了,我在移动构造函数后面加了个 noexcept

MyString(MyString&& other) noexcept
    : data_(other.data_), size_(other.size_) { ... }

为什么这么写?

因为标准库容器(比如 std::vector)在重新分配内存时,会优先使用移动构造函数。但如果移动构造函数可能抛出异常,容器就会退而使用拷贝构造函数——因为拷贝构造至少能保证源对象不变,异常安全。

换句话说:如果你的移动构造函数没有标记 noexcept,容器可能根本不会用它!

规则: 移动构造函数和移动赋值运算符,只要不抛异常,就一定要加 noexcept。这不仅是文档,更是性能的关键。

我记得有一次优化一个大型游戏服务器,里面有个 std::vector<PlayerSession>,每次扩容都要拷贝所有玩家数据。我加了移动构造函数,但忘了加 noexcept。结果性能纹丝不动。查了半天才发现,std::vector 压根没调用我的移动构造,还是老老实实走拷贝。加上 noexcept 后,性能直接提升了 3 倍。

4.6 移动构造函数的正确实现模式

总结一下,一个标准的移动构造函数应该长这样:

class MyBuffer {
public:
    // 移动构造函数
    MyBuffer(MyBuffer&& other) noexcept
        : ptr_(other.ptr_)          // 1. 偷资源
        , size_(other.size_)
    {
        // 2. 源对象置空
        other.ptr_ = nullptr;
        other.size_ = 0;
    }

    // 拷贝构造函数(如果需要)
    MyBuffer(const MyBuffer& other)
        : ptr_(new char[other.size_])
        , size_(other.size_)
    {
        memcpy(ptr_, other.ptr_, size_);
    }

    // 析构函数
    ~MyBuffer() { delete[] ptr_; }

private:
    char* ptr_;
    size_t size_;
};

核心就三步:

  1. 偷: 把源对象的资源指针直接拿过来
  2. 清: 把源对象的指针置空,大小置零
  3. 保: 标记为 noexcept,保证容器能放心使用

你想想看,这三步做完,源对象就变成了一个「空壳子」。它还能正常析构(因为 delete nullptr 是安全的),但不再拥有任何资源。这就是移动语义的精髓——所有权转移。

4.7 知识体系图

下面这张图帮你理清移动构造函数在整个移动语义中的位置:

移动构造函数知识体系 移动构造函数 定义:T(T&&) noexcept 实现:偷资源 + 置空源 调用:右值 / std::move 参数必须是右值引用 && 必须标记 noexcept 直接转移指针所有权 源对象置 nullptr / 0 临时对象自动匹配 std::move 强制转换左值 目标:O(1) 转移资源,避免深拷贝

从图上可以看得很清楚:移动构造函数的核心就是「定义-实现-调用」三个环节。定义时用 &&noexcept,实现时偷资源并置空源对象,调用时由右值或 std::move 触发。三者缺一不可。

4.8 避坑指南

最后,分享几个我踩过的坑:

  • 别忘了置空源对象: 这是最常见的 bug。移动后源对象必须处于「可安全析构」的状态,否则 double free 等着你。
  • 别忘了加 noexcept: 标准库容器会检查这个标记。不加的话,移动语义可能根本不会生效。
  • 不要对 const 对象使用移动: const T&& 虽然能编译,但移动构造函数无法修改源对象,实际上会退化成拷贝。
  • 移动后不要再使用源对象: 除非你重新给它赋值。这是语义约定,不是编译器强制。

嗯,移动构造函数就聊到这里。说白了,它就是 C++11 给我们的一个「偷懒」工具——不用再傻傻地复制了,直接拿过来用就行。但偷懒也要偷得优雅,该加的 noexcept 要加,该置空的要置空,这样才能写出既高效又安全的代码。