40. 右值引用:移动构造函数、移动赋值运算符

右值引用,这名字听起来挺唬人的。说白了,它就是C++11引入的一种新引用类型,专门用来绑定那些「即将销毁的临时对象」。我当年第一次看到这个特性时,心里想的是:这不就是多了一种引用写法吗?后来在项目里被拷贝构造的性能坑过几次,才真正明白它的价值。

为什么需要右值引用?

先看一个场景。你写了个函数,返回一个很大的vector:

std::vector<int> createBigVector() {
    std::vector<int> v(1000000, 42);
    return v;  // 这里发生了什么?
}

在C++11之前,这个返回过程会触发一次拷贝构造。100万个int,逐个复制,性能开销巨大。编译器可能会做返回值优化(RVO),但并不是所有情况都能优化掉。

你想想看,函数返回的v马上就要销毁了,我们却还要费力地拷贝它的数据,这不是浪费吗?右值引用就是来解决这个问题的——它让我们能「偷」走临时对象的资源。

左值与右值

先理清两个基本概念:

  • 左值(lvalue):有名字、可以取地址的表达式。比如变量名、数组元素。
  • 右值(rvalue):临时对象、字面量,没有持久的内存地址。比如42、函数返回值。

右值引用用&&声明:

int&& rref = 42;          // 绑定到字面量
int&& rref2 = getTemp();  // 绑定到临时对象

嗯,这里要注意:右值引用本身是左值,因为它有名字。这个细节很重要,后面写移动构造函数时会用到。

移动构造函数

移动构造函数的核心思想:把源对象的资源「转移」给目标对象,然后把源对象置为空状态

来看一个自定义的String类:

class MyString {
private:
    char* data_;
    size_t size_;

public:
    // 构造函数
    MyString(const char* str) {
        size_ = strlen(str);
        data_ = new char[size_ + 1];
        memcpy(data_, str, size_ + 1);
    }

    // 拷贝构造函数
    MyString(const MyString& other) {
        size_ = other.size_;
        data_ = new char[size_ + 1];
        memcpy(data_, other.data_, size_ + 1);
        std::cout << "拷贝构造\n";
    }

    // 移动构造函数
    MyString(MyString&& other) noexcept 
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
        std::cout << "移动构造\n";
    }

    ~MyString() {
        delete[] data_;
    }
};

看到区别了吗?拷贝构造要重新分配内存、复制数据。移动构造只是把指针「偷」过来,然后把源指针置空。这个操作是O(1)的,跟数据量无关。

关键点:移动构造函数必须把源对象置于「有效但未指定」的状态。通常的做法是置为null或空状态,这样析构函数不会出问题。

移动赋值运算符

移动赋值运算符的写法类似,但多了一个自我赋值检查:

MyString& operator=(MyString&& other) noexcept {
    if (this != &other) {  // 自我赋值检查
        delete[] data_;    // 释放当前资源

        data_ = other.data_;
        size_ = other.size_;

        other.data_ = nullptr;
        other.size_ = 0;
    }
    return *this;
}

我习惯把移动赋值写成三步:释放自己 → 偷取对方 → 清空对方。这个模式很清晰,不容易出错。

注意:移动赋值运算符的参数是MyString&&,不是const MyString&&。因为我们要修改源对象(清空它),所以不能加const。

什么时候会触发移动?

移动构造和移动赋值不会自动调用,需要满足条件:

  • 参数是右值(临时对象)
  • 或者显式使用std::move()转换

举个例子:

MyString s1("hello");
MyString s2 = s1;              // 拷贝构造,s1是左值
MyString s3 = std::move(s1);   // 移动构造,std::move把s1转成右值
MyString s4 = createString();  // 移动构造,返回值是右值

我曾经在项目里看到有人滥用std::move,把每个变量都包一层move。这是不对的。移动之后源对象就空了,如果你后面还用,就会出问题。

noexcept的重要性

移动构造函数和移动赋值运算符应该标记为noexcept。为什么?

标准库容器(比如std::vector)在重新分配内存时,会优先使用移动操作。但如果移动操作可能抛异常,容器就会退回到拷贝操作——因为拷贝至少能保证强异常安全。

你想想看,如果移动操作不标记noexcept,vector扩容时就不会用移动,性能优势就没了。我见过一个线上问题,就是因为忘了加noexcept,导致大量数据拷贝,性能直接掉了一个数量级。

知识结构图

右值引用 && 左值:有名字、可取地址 右值:临时对象、字面量 移动构造函数 T(T&& other) noexcept 移动赋值运算符 T& operator=(T&&) noexcept 核心原则:偷资源 + 置空源对象 必须 noexcept,保证异常安全 触发:右值 或 std::move() 注意:移动后源对象不可用

避坑指南

我总结几个常见的坑:

  1. 忘记加noexcept:前面说过了,这会导致vector等容器不用移动操作。
  2. 移动后继续使用源对象:移动后的对象处于「空壳」状态,除了析构和赋值,不要做其他操作。
  3. 在const对象上使用std::movestd::move(const_obj)会得到const T&&,这不会触发移动构造,而是退回到拷贝构造。
  4. 自我移动赋值:虽然不常见,但最好加上检查。

个人建议:如果你的类管理了动态资源(堆内存、文件句柄、网络连接等),一定要实现移动语义。这不仅是性能优化,也是现代C++的规范做法。我一般在写完拷贝构造和析构函数后,顺手就把移动构造和移动赋值也写了,反正代码量不大。

总结

右值引用和移动语义,说白了就是「资源转移」的语法糖。它让我们能高效地处理临时对象,避免不必要的拷贝。记住三个要点:

  • 移动构造/赋值用&&参数,标记noexcept
  • 实现逻辑:偷资源 + 置空源对象
  • std::move把左值转成右值,但别滥用

嗯,掌握了这些,你的C++代码就能告别很多不必要的性能浪费了。


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