7. 移动构造函数(C++11):移动语义基础、移动构造函数语法、移动构造函数与拷贝构造函数的区别

好,咱们来聊聊移动构造函数。说实话,C++11 引入移动语义之前,我写代码时经常被一些「不必要的拷贝」搞得头疼。尤其是那些临时对象传来传去的时候,性能损耗简直让人抓狂。

你想想看,一个函数返回一个巨大的 std::vector,传统拷贝构造函数会老老实实地把数据复制一份。但问题是——那个临时对象马上就要销毁了,复制它干嘛?直接「偷」走它的资源不香吗?

嗯,这就是移动构造函数要干的事。

移动语义基础

移动语义的核心思想很简单:把资源从一个对象「转移」到另一个对象,而不是复制。说白了,就是「你既然不用了,那你的东西我就拿走了」。

我个人习惯把移动语义理解为「借东西不还」——源对象把自己的资源借给目标对象,然后自己进入一个「空壳」状态。这个空壳状态是合法的,可以被安全析构,但不能再持有原来的资源。

关键点:移动操作完成后,源对象必须处于「有效但未指定」的状态。它仍然可以被析构、被赋值,但不能假定它持有任何特定值。

我在项目中遇到过这样一个场景:一个类内部维护了一个 100MB 的缓冲区。如果每次拷贝都复制这 100MB,性能直接崩掉。用移动语义,只需要交换几个指针——O(1) 操作,爽得很。

移动构造函数语法

移动构造函数的语法和拷贝构造函数很像,但参数是右值引用:

class MyBuffer {
public:
    // 默认构造函数
    MyBuffer(size_t size) : size_(size), data_(new int[size]) {
        std::cout << "构造: 分配 " << size << " 个元素\n";
    }

    // 拷贝构造函数
    MyBuffer(const MyBuffer& other) : size_(other.size_), data_(new int[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
        std::cout << "拷贝构造: 深拷贝 " << size_ << " 个元素\n";
    }

    // 移动构造函数 —— 重点在这里
    MyBuffer(MyBuffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        // 把源对象的指针置空,让它不再持有资源
        other.data_ = nullptr;
        other.size_ = 0;
        std::cout << "移动构造: 偷走资源,源对象已置空\n";
    }

    // 析构函数
    ~MyBuffer() {
        delete[] data_;
        std::cout << "析构: 释放资源\n";
    }

private:
    size_t size_;
    int* data_;
};

注意几个细节:

  • 参数是 MyBuffer&& —— 两个 & 表示右值引用
  • 函数标记为 noexcept —— 移动操作不应该抛异常,这能让标准库更高效地使用你的类型
  • 源对象的资源指针被置为 nullptr —— 这是必须的,否则析构时会 double free

小技巧:我习惯在移动构造函数里用 noexcept。为什么?因为 std::vector 扩容时,如果移动构造函数是 noexcept 的,它会优先使用移动;否则只能退化为拷贝。这个坑我曾经踩过,性能直接掉了 10 倍。

什么时候触发移动构造?

移动构造函数在以下场景被调用:

  1. 函数返回局部对象(返回值优化失效时)
  2. std::move() 显式转换
  3. 临时对象作为参数传递(比如 func(MyBuffer(100))
  4. 标准容器的扩容操作(如果移动构造是 noexcept 的)
MyBuffer createBuffer() {
    MyBuffer buf(1000);
    // ... 做一些操作
    return buf;  // 优先移动,而非拷贝
}

int main() {
    MyBuffer a(100);
    MyBuffer b = std::move(a);  // 显式调用移动构造
    // 此时 a 处于空壳状态,不能再使用
    MyBuffer c = createBuffer(); // 函数返回触发移动
}

移动构造函数与拷贝构造函数的区别

咱们用一张表来对比:

对比维度 拷贝构造函数 移动构造函数
参数类型 const T&(左值引用) T&&(右值引用)
资源处理 深拷贝,复制所有数据 浅拷贝 + 转移所有权
性能 O(n),与资源大小成正比 O(1),通常只是指针交换
源对象状态 源对象不变,仍可用 源对象被置空,不可再用
异常安全 通常安全(可分配内存失败) 应标记 noexcept,不抛异常
适用场景 需要保留源对象时 源对象即将销毁时

说白了,拷贝构造函数是「复制一份给你,我留着我的」;移动构造函数是「你的就是我的,你的我拿走了,你爱咋咋地」。

警告:千万不要在移动构造后继续使用源对象!我曾经见过一个同事,移动完一个 std::string 后还去访问它,结果程序崩溃了。虽然标准库保证移动后的字符串处于有效状态,但具体值是未指定的——你没法依赖它。

移动语义的核心逻辑

下面这张图展示了移动构造和拷贝构造的流程对比:

拷贝构造 vs 移动构造 流程对比 拷贝构造函数 源对象 data → [1,2,3,4,5] 目标对象 data → [新分配内存] 深拷贝 分配新内存,复制所有数据 源对象保持不变 时间复杂度 O(n) 移动构造函数 源对象 data → [1,2,3,4,5] 目标对象 data → [偷来的资源] 转移指针 源对象(移动后) data → nullptr 置空 只转移指针,不分配新内存 源对象被置空,不可再用 时间复杂度 O(1)

从图上可以看得很清楚:拷贝构造需要分配新内存、复制所有数据;而移动构造只是把指针「偷」过来,再把源对象的指针置空。这就是为什么移动操作能带来数量级的性能提升。

什么时候用移动,什么时候用拷贝?

我个人的经验是:

  • 默认用拷贝,除非你能确定源对象不再需要
  • 函数返回局部对象时,编译器会自动选择移动(甚至直接优化掉)
  • 显式传递临时对象时,用 std::move 触发移动语义
  • 容器扩容时,如果移动构造是 noexcept 的,标准库会自动用移动

避坑指南:我曾经在代码里写了一个移动构造函数,但忘了把源对象的指针置空。结果源对象析构时把资源释放了,目标对象拿着一个悬空指针——程序崩溃得莫名其妙。记住:移动构造不只是「偷」,还要「擦屁股」。

嗯,移动构造函数就聊到这儿。记住它的核心:资源转移,而非复制。写对了,性能起飞;写错了,bug 满天飞。


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