【C++11篇(二)】右值引用、移动语义保姆级讲解!


大家好,欢迎来到 huangjin007_ 的博客
个人主页:huangjin007_
🔥 文章收录专栏:零基础入门C++
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇


C++11篇(二) ------ 右值引用、移动语义详解

  本篇文章将由浅入深,带你循序渐进地掌握C++11中的右值引用与移动语义,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧

文章目录

  • [C++11篇(二) ------ 右值引用、移动语义详解](#C++11篇(二) —— 右值引用、移动语义详解)
    • [1. 左值与右值------看"能不能取地址"](#1. 左值与右值——看“能不能取地址”)
      • [1.1 什么是左值?](#1.1 什么是左值?)
      • [1.2 什么是右值?](#1.2 什么是右值?)
    • [2. 左值引用与右值引用](#2. 左值引用与右值引用)
      • [2.1 两种引用的基本语法](#2.1 两种引用的基本语法)
      • [2.2 一些重要的绑定规则](#2.2 一些重要的绑定规则)
      • [2.3 右值引用变量本身是左值](#2.3 右值引用变量本身是左值)
      • [2.4 底层实现怎么看?](#2.4 底层实现怎么看?)
    • [3. 引用延长临时对象的生命周期](#3. 引用延长临时对象的生命周期)
    • [4. 根据左值/右值选择函数重载](#4. 根据左值/右值选择函数重载)
      • [4.1 重载决议规则](#4.1 重载决议规则)
      • [4.2 右值引用变量仍是左值的实验](#4.2 右值引用变量仍是左值的实验)
    • [5. 移动语义的使用场景](#5. 移动语义的使用场景)
      • [5.1 左值引用的能力边界](#5.1 左值引用的能力边界)
      • [5.2 移动构造与移动赋值](#5.2 移动构造与移动赋值)
      • [5.3 解决"按值返回局部对象"的痛点](#5.3 解决“按值返回局部对象”的痛点)
      • [5.4 容器接口的右值引用版本](#5.4 容器接口的右值引用版本)
    • [6. 值类别的再细分(了解即可)](#6. 值类别的再细分(了解即可))
    • [7. 引用折叠](#7. 引用折叠)
      • [7.1 什么是引用折叠?](#7.1 什么是引用折叠?)
      • [7.2 实验引用折叠](#7.2 实验引用折叠)
      • [7.3 万能引用的推导细节](#7.3 万能引用的推导细节)
    • [8. 完美转发](#8. 完美转发)
      • [8.1 问题:万能引用转发时丢失右值属性](#8.1 问题:万能引用转发时丢失右值属性)
      • [8.2 解决方案:`std::forward`](#8.2 解决方案:std::forward)
      • [8.3 `forward` 的实现原理](#8.3 forward 的实现原理)
      • [8.4 完整测试代码](#8.4 完整测试代码)
    • 结语:

1. 左值与右值------看"能不能取地址"

1.1 什么是左值?

  左值 ,简单理解就是:能取到地址、有名字、有持久状态的表达式 。比如定义的变量、解引用的指针、数组的元素等等。左值可以出现在赋值号左边,也可以出现在右边。

cpp 复制代码
int* p = new int(0);   // p 是左值
int b = 1;             // b 是左值
const int c = b;       // c 是 const 左值(不能修改,但可以取地址)
*p = 10;               // *p 是左值
string s("1111111");
s[0] = 'x';            // s[0] 是左值

// 都可以取地址
cout << &c << endl;
cout << (void*)&s[0] << endl;

  左值的英文是 lvalue ,传统上认为是 left value;现代 C++ 更倾向于解释为 loactor value (有存储位置的值)。核心特征就是:可以取地址

1.2 什么是右值?

  右值 是"不能取地址"的表达式,通常是临时对象字面常量 。它们一般存在寄存器里,或者只是个出现一次的中间结果。右值只能出现在赋值号右边,不能出现在左边。

cpp 复制代码
double x = 1.1, y = 2.2;
10;              // 字面常量,右值
x + y;           // 表达式临时结果,右值
fmin(x, y);      // 函数返回的临时值,右值
string("11111"); // 匿名临时对象,右值

// 下面这些都不能编译,因为它们取不到地址
// cout << &10 << endl;
// cout << &(x + y) << endl;
// cout << &string("11111") << endl;

  右值英文是 rvalue,传统上认为是 right value,现在多解释为 read value (只读值),提供数据但不提供可靠地址。左右值的核心区别就是"能不能取地址"


2. 左值引用与右值引用

2.1 两种引用的基本语法

  C++ 中,给一个对象起别名就叫引用。原来学的 int &r = a; 是左值引用,只能绑定左值。C++11 新增了右值引用 int &&rr = ...;,专门用来绑定右值。

cpp 复制代码
// 左值引用:给左值取别名
int& r1 = b;
int*& r2 = p;
int& r3 = *p;
string& r4 = s;
char& r5 = s[0];

// 右值引用:给右值取别名
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
string&& rr4 = string("11111");

2.2 一些重要的绑定规则

  1. 左值引用不能直接绑定右值,但 const 左值引用可以绑定右值

    cpp 复制代码
    const int& rx1 = 10;       // OK
    const string& rx4 = string("11111"); // OK

   这个规则在 C++98 就有了,它能让函数同时接受左值和右值实参,提高了兼容性。

  1. 右值引用不能直接绑定左值,但可以通过 std::move 把左值"变成"右值

    cpp 复制代码
    int&& rrx1 = std::move(b);       // OK,b 被转为右值
    string&& rrx4 = std::move(s);    // OK
    string&& rrx5 = (string&&)s;     // move 的本质就是强制类型转换

      std::move 并不真的移动任何东西,它只是做了一次类型转换:把参数变成右值引用,让你可以触发移动语义。

2.3 右值引用变量本身是左值

  任何有名字的变量表达式,属性都是左值。即便它是一个右值引用类型。看下面这个例子:

cpp 复制代码
int&& rr1 = 10;   // rr1 是右值引用,但 rr1 这个变量本身是左值
cout << &rr1 << endl;   // 可以取地址,证明它是左值

int& r6 = r1;           // OK,r1 是左值引用,也是左值
// int&& rrx6 = rr1;    // 错误!rr1 虽然是右值引用类型,但变量表达式是左值,
                        // 右值引用不能直接绑定左值
int&& rrx6 = std::move(rr1); // 必须再用 move 转一次

  这个设计初看很绕,但它是为了安全------后面讲到移动语义和完美转发时,你会体会到这样设计的精妙之处。

2.4 底层实现怎么看?

  语法层面上,左值引用和右值引用都是取别名,不新开空间。但汇编代码中,底层都是通过指针实现的,和指针没本质区别。所以不要死抠底层去理解上层语义,语法上是什么语义,就按什么语义用。


3. 引用延长临时对象的生命周期

  很多时候我们会产生一个临时对象,如果直接使用它,它在这一行结束后就销毁了。C++ 提供了一种机制:用一个引用绑定这个临时对象,它的生命周期会被延长,和这个引用一样长

cpp 复制代码
string s1 = "Test";
const string& r2 = s1 + s1;  // const 左值引用延长了临时对象 s1+s1 的生命
// r2 += "Test";             // 错误!const 引用不能修改

string&& r3 = s1 + s1;       // 右值引用也能延长生命,且可以修改
r3 += "Test";                // 正确,r3 不是 const
cout << r3 << '\n';

  两种引用都能延长生命周期,区别在于右值引用绑定的临时对象是可修改的。这为后面实现移动语义提供了便利。


4. 根据左值/右值选择函数重载

4.1 重载决议规则

  有了右值引用后,我们可以为同一个函数写出三个版本,分别匹配左值、const 左值和右值参数。

cpp 复制代码
void f(int& x) 
{
    cout << "左值引用重载 f(" << x << ")\n";
}
void f(const int& x) 
{
    cout << "到const的左值引用重载 f(" << x << ")\n";
}
void f(int&& x) 
{
    cout << "右值引用重载 f(" << x << ")\n";
}

int main() 
{
    int i = 1;
    const int ci = 2;
    f(i);            // 调 f(int&)
    f(ci);           // 调 f(const int&)
    f(3);            // 调 f(int&&),如果没有这个重载,会退而求其次调 f(const int&)
    f(std::move(i)); // 调 f(int&&)
}

规则: 左值优先匹配左值引用,const 左值匹配 const 左值引用,右值匹配右值引用。

4.2 右值引用变量仍是左值的实验

延续之前的结论:

cpp 复制代码
int&& x = 1;
f(x);             // 调 f(int&),因为 x 是左值
f(std::move(x));  // 调 f(int&&)

  x 虽然是右值引用类型,但在调用函数时,它作为表达式是左值,所以匹配了 f(int&)。只有用 move 再转一次,才会匹配右值版本。这个特性在实现完美转发时会再次遇到。


5. 移动语义的使用场景

5.1 左值引用的能力边界

  左值引用已经能解决大量拷贝问题:传参时写 const T&,返回时如果能保证对象生命周期,也可以返回引用。比如:

cpp 复制代码
const string& getString();   // 如果返回的对象在函数外还存在

  但当返回的对象是函数内的局部变量 时,左值引用就无能为力了。因为函数一结束,局部对象就销毁了,返回它的引用会导致悬垂引用 (一个引用绑定到了已经被销毁、不存在的内存空间上)。

  在 C++98 里,只能老老实实按值返回,接受一次拷贝(或通过输出参数绕开)。C++11 的移动语义,就是在这种"必须按值返回局部对象"的场景下大放异彩的。

5.2 移动构造与移动赋值

  移动构造函数移动赋值运算符* 它们都在接收一个右值时,直接"窃取"对方的资源,而不是重新分配内存拷贝一份。

  这对 stringvector 这类管理堆内存的类意义重大。下面是一个简化版 string,同时实现了拷贝和移动版本:

cpp 复制代码
namespace hj 
{
    class string 
    {
    public:
        // 普通构造
        string(const char* str = "")
            : _size(strlen(str)), _capacity(_size) 
            {
            cout << "string(char* str) 构造" << endl;
            _str = new char[_capacity + 1];
            strcpy(_str, str);
        }

        // 拷贝构造(深拷贝)
        string(const string& s) : _str(nullptr) 
        {
            cout << "string(const string& s) 拷贝构造" << endl;
            reserve(s._capacity);
            for (auto ch : s) push_back(ch);
        }

        // 移动构造(窃取资源)
        string(string&& s) 
        {
            cout << "string(string&& s) 移动构造" << endl;
            swap(s);  // 直接交换指针,无需新分配内存
        }

        // 拷贝赋值
        string& operator=(const string& s) 
        {
            cout << "string& operator=(const string& s) 拷贝赋值" << endl;
            if (this != &s) {
                _str[0] = '\0';
                _size = 0;
                reserve(s._capacity);
                for (auto ch : s) push_back(ch);
            }
            return *this;
        }

        // 移动赋值
        string& operator=(string&& s) 
        {
            cout << "string& operator=(string&& s) 移动赋值" << endl;
            swap(s);
            return *this;
        }

        ~string() 
        {
            cout << "~string() 析构" << endl;
            delete[] _str;
            _str = nullptr;
        }

        void swap(string& ss) 
        {
            std::swap(_str, ss._str);
            std::swap(_size, ss._size);
            std::swap(_capacity, ss._capacity);
        }
        // ... 其他成员函数,如 reserve、push_back 等
    private:
        char* _str = nullptr;
        size_t _size = 0;
        size_t _capacity = 0;
    };
}

移动构造和移动赋值的核心

  把源对象的资源指针直接"拿"过来,再让源对象进入一个合法但"空"的状态(这里通过 swap 实现,旧资源在 s 析构时自动释放)。这样就避免了重新分配内存和拷贝数据。

5.3 解决"按值返回局部对象"的痛点

  来看一个经典的场景:字符串相加后返回结果。

cpp 复制代码
namespace hj 
{
    string addString(string num1, string num2) 
    {
        string str;
        int end1 = num1.size() - 1, end2 = num2.size() - 1;
        int next = 0;
        while (end1 >= 0 || end2 >= 0) 
        {
            int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
            int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
            int ret = val1 + val2 + next;
            next = ret / 10;
            ret = ret % 10;
            str += ('0' + ret);
        }
        if (next == 1) str += '1';
        reverse(str.begin(), str.end());
        return str;  // 返回局部对象
    }
}

情况一:接收返回值构造新对象

cpp 复制代码
hj::string ret = hj::addString("1111", "2222");

只有拷贝构造,没有移动构造的场景:

  • 在 C++98 下(且关闭编译器优化),这里会发生:str 被拷贝到一个临时对象,临时对象再拷贝给 ret,一共两次拷贝构造
  • 早期的编译器优化(比如vs2019 debug环境下的编译器),会把连续步骤中的拷贝合二为一变为一次拷贝构造。

  • 随着编译器的不断更新,现在开启优化(比如在vs2019的release和vs2022的debug和release)时,编译器会进行更激进的处理------三次构造合为一次,直接在 ret 的位置原地构造

有移动构造时的场景:

  • 如果写了移动构造函数,关闭优化时这两次拷贝会变为两次移动构造 ,因为临时对象和 str 在返回时都被识别为右值。

  • 编译器优化下的场景:

    • 左边的编译器一代优化将连续步骤中的拷贝合二为一变为一次移动构造。
      右边的
    • 右边的编译器二代优化将str对象的构造,str拷贝构造临时对象,临时对象拷贝构造ret对象,合三为一,变为直接构造。

情况二:先定义对象,再赋值给它

cpp 复制代码
hj::string ret;
ret = hj::addString("1111", "2222");

右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景:

  • 关闭优化的话会:先构造临时对象(拷贝构造),然后赋值给 ret(拷贝赋值),共一次拷贝构造 + 一次拷贝赋值
  • 编译器优化场景下:

右值对象赋值,既有拷贝构造和拷贝赋值,也有移动构造和移动赋值的场景:

  • 写了移动语义,就变成一次移动构造 + 一次移动赋值

  • 开启编译器优化时,str 实际上直接成了临时对象的别名,直接在赋值时移动资源,效率极高。

总结:移动语义让"按值返回"不再是性能瓶颈,编译器优化则进一步锦上添花。

  • 深拷贝的自定义类型:如vector/string/map等,实现移动构造和移动赋值是有很大的价值的。
  • 浅拷贝的自定义类型:如Date/pair<int,int>等,则不需要实现移动构造和移动赋值。

5.4 容器接口的右值引用版本

  STL 中,push_backinsert 这类方法都增加了右值引用重载。当你传入左值时,容器内部调用拷贝构造;当你传入右值时,则调用移动构造。对于存放大对象的容器,这能带来巨大的性能提升。

cpp 复制代码
void push_back(const T& x)
{
	insert(end(), x);
}
void push_back(T&& x)
{
	insert(end(), move(x));
}

iterator insert(iterator pos, const T& x)
{
	Node* cur = pos._node;
	Node* newnode = new Node(x);
	Node* prev = cur->_prev;
	// prev newnode cur
	prev->_next = newnode;
	newnode->_prev = prev;
	newnode->_next = cur;
	cur->_prev = newnode;
	return iterator(newnode);
}
iterator insert(iterator pos, T&& x)
{
	Node* cur = pos._node;
	Node* newnode = new Node(move(x));
	Node* prev = cur->_prev;
	// prev newnode cur
	prev->_next = newnode;
	newnode->_prev = prev;
	newnode->_next = cur;
	cur->_prev = newnode;
	return iterator(newnode);
}

  后续还会接触 emplace 系列接口,它们配合可变参数模板能实现更直接的原地构造。


6. 值类别的再细分(了解即可)

C++11 把右值进一步分为纯右值(prvalue)将亡值(xvalue)

  • 纯右值(prvalue) :字面常量、临时对象、不具名的函数返回值(比如 str1 + str242a++)。基本等于 C++98 里的右值概念。
  • 将亡值(xvalue) :即将被移动的对象。典型的是 std::move(x) 的返回值,或者 static_cast<X&&>(x) 的结果。

泛左值(glvalue) = 左值 + 将亡值。纯右值和将亡值合起来就是右值(rvalue)。

  日常编程中我们不需要刻意区分这些,只要知道 move 返回的是一个将亡值,它能够触发移动语义即可。


7. 引用折叠

7.1 什么是引用折叠?

  C++ 不允许我们直接写引用的引用(如 int& &&),但在模板推导typedef 过程中,可能会间接形成引用的引用。这时编译器会按照以下规则处理:

  • & + & --> &
  • & + && --> &
  • && + & --> &
  • && + && --> &&

  简单说就是:只有两个右值引用叠加才是右值引用,其他组合都是左值引用

7.2 实验引用折叠

cpp 复制代码
template<class T>
void f1(T& x) {}   // 由于引用折叠,f1 实例化后总是左值引用

template<class T>
void f2(T&& x) {}  // f2 实例化后可以是左值引用,也可以是右值引用

int main() 
{
    typedef int&  lref;
    typedef int&& rref;
    int n = 0;

    lref&   r1 = n; // int& &  -> int&
    lref&&  r2 = n; // int& && -> int&
    rref&   r3 = n; // int&& & -> int&
    rref&&  r4 = 1; // int&& && -> int&&

    f1<int>(n);          // T=int, void f1(int& x)
    f1<int&>(n);         // T=int&, void f1(int& & x) -> void f1(int& x)
    f1<int&&>(n);        // T=int&&, void f1(int&& & x) -> void f1(int& x)
    f1<const int&&>(0);  // void f1(const int& x)

    f2<int>(0);          // T=int, void f2(int&& x)     -> 右值引用
    f2<int&>(n);         // T=int&, void f2(int& && x)  -> void f2(int& x) 左值引用
    f2<int&&>(0);        // T=int&&, void f2(int&& && x) -> void f2(int&& x) 右值引用
}

  注意 f2 这样的形式 T&& 在模板中,由于折叠规则的存在,它既能变成左值引用也能变成右值引用,所以经常被称为万能引用(universal reference)

7.3 万能引用的推导细节

cpp 复制代码
template<class T>
void Function(T&& t) 
{
    int a = 0;
    T x = a;  // 观察 T 的推导结果
}

int main() 
{
    Function(10);          // 实参是右值 int,T 推导为 int,  形参是 int&&
    int a;
    Function(a);           // 实参是左值 int,T 推导为 int&, 折叠后形参是 int&
    Function(std::move(a));// 实参是右值 int,T 推导为 int,  形参是 int&&
    const int b = 8;
    Function(b);           // 实参是 const 左值,T 推导为 const int&, 形参 const int&
}

  当传左值时 T 推导为 int&,经过 int& && 折叠为 int&,所以形参就是一个左值引用。

  当传右值时 T 推导为 int,形参就是原样的 int&&。这个机制让同一个模板函数可以"原封不动"地接受左值和右值。


8. 完美转发

8.1 问题:万能引用转发时丢失右值属性

  我们接着用上面的 Function。现在如果 Function 内部需要把参数 t 转发给另一个函数 Fun

cpp 复制代码
void Fun(int& x)  { cout << "左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }

template<class T>
void Function(T&& t) 
{
    Fun(t);  // 直接转发
}

  调用 Function(10);,我们期望 t 是右值引用,应该调用 Fun(int&&)。但实际输出是 "左值引用"。原因正是前面反复强调的:t 作为一个变量表达式,它是左值 ,哪怕它的类型是右值引用。所以 Fun(t) 永远匹配左值版本。

8.2 解决方案:std::forward

  我们需要一个工具,当参数原本是左值时,把它原样转发为左值;当参数原本是右值时,把它转发为右值 。这就是 std::forward

cpp 复制代码
template<class T>
void Function(T&& t) 
{
    Fun(std::forward<T>(t));  // 完美转发
}

  现在 Function(10) 会正确调用 Fun(int&&)Function(a) 会调用 Fun(int&)

8.3 forward 的实现原理

标准库中 forward 的核心实现非常简单(简化版):

cpp 复制代码
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept {
    return static_cast<_Ty&&>(_Arg);
}

它借助引用折叠规则完成"有条件转换":

  • 如果 Function 接收到左值,T 推导为 int&forward_Tyint&_Ty&& 折叠为 int&,返回左值引用,转发给左值版本。
  • 如果 Function 接收到右值,T 推导为 intforward_Tyint_Ty&& 就是 int&&,返回右值引用,转发给右值版本。

  因此,std::forward 并没有做什么神奇操作,它只是根据模板参数把参数原样转成对应的值类别返回,保证参数在多层调用中"完美"地保持原样。

8.4 完整测试代码

cpp 复制代码
void Fun(int& x)            { cout << "左值引用" << endl; }
void Fun(const int& x)      { cout << "const 左值引用" << endl; }
void Fun(int&& x)           { cout << "右值引用" << endl; }
void Fun(const int&& x)     { cout << "const 右值引用" << endl; }

template<class T>
void Function(T&& t) {
    Fun(std::forward<T>(t));   // 完美转发
}

int main() 
{
    Function(10);               // 右值引用
    int a;
    Function(a);                // 左值引用
    Function(std::move(a));     // 右值引用
    const int b = 8;
    Function(b);                // const 左值引用
    Function(std::move(b));     // const 右值引用
}

输出:

  一切按照参数原本的值类别正确分发。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
孟浩浩3 小时前
JAVA SpringAI+阿里云百炼应用开发
java·开发语言·阿里云
碧蓝的水壶3 小时前
数据转换过程
java·开发语言·windows
2501_947575809 小时前
计算机毕业设计之jsp开山车行二手车交易系统
java·开发语言·hadoop·python·信息可视化·django·课程设计
骑士雄师9 小时前
java面试题 4:鉴权
java·开发语言
时间的拾荒人11 小时前
C语言字符函数与字符串函数完全指南
c语言·开发语言
浆果020711 小时前
NanoTrack C++ — RK3588 实时目标跟踪
c++·目标跟踪·rk3588
ysa05103011 小时前
【并查集】判环
c++·笔记·算法
2501_9481069111 小时前
计算机毕业设计之基于jsp教科研信息共享系统
java·开发语言·信息可视化·spark·课程设计
持力行11 小时前
C/C++ 中的 char*:它标识数组吗?为什么能用下标访问?
c语言·c++