• 深入理解右值引用与移动语义


    写在前面

    本文主要为大家梳理以下几个问题:

    • 什么是右值
    • 右值引用的意义与使用场景
    • std::move 函数的本质
    • 如何编写移动构造函数
    • 万能引用与完美转发

    参考资料:

    1. 《一文读懂C++右值引用与std::move》

    2. 《C++高阶知识:深入分析移动构造函数及其原理》

    3. 《Value categories》

    4. 《C++11的 value category 以及 move semantics》

    由于作者才疏学浅,理解欠缺的地方欢迎大家指正


    1. 什么是右值,什么是左值?

     每个 C++ 表达式(包括操作符和其操作数、字面值、变量名等)具有两个独立的属性:类型和值类别
     类型(type)大家都不陌生,指的是表达式的数据类型,它定义了表达式的取值范围和可执行的操作。例如,一个整数表达式的类型可以是 int,一个浮点数表达式的类型可以是 float。
     值类别(value category)可以理解为表达式的身份和可移动性。根据 C++ 标准,有三种最主要的值类别:右值(rvalue)、左值(lvalue)、将亡值(xvalue),他们三者的关系如下:
    在这里插入图片描述

    • 身份决定了它是否具有表达式寻址性,即我们是否可以获取其在内存中的地址
    • 可移动性如果出现在赋值,初始化等语句中,是否会使语句呈现移动语义

    上面说的有些抽象,我们结合具体的例子来分析,根据表现出的特征进行区分:

    [右值与右值引用]:

    // 以下都是常见的右值
    10;
    x + y;
    fmin(x, y);
    
    // 右值引用
    int&& rr1 = 10;
    double&& rr2 = x + y;             
    double&& rr3 = fmin(x, y); 		  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等
    • 右值不能放在赋值符号的左边,不信你试试 10 = x + y
    • 对右值的引用就是右值引用,用 && 表示
    • 不能对右值取地址,不信你试试 int* p = &10;

    [左值与左值引用]:

    // 以下a、b、pa都是左值
    int a = 10;    
    int b = a;
    int* pa = &a; 
    
    // 左值引用
    int& rla = a;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 左值可以出现在赋值符号的左边和右边
    • 可以取它的地址,可以为他赋值

    下面来考虑一些疑难问题:

    1.1右值引用可以引用左值吗

    可以。 std::move() 函数可以将左值强转为右值。没错,就是强制类型转换,我们后面将结合源码具体谈到:

    int a = 10;
    int&& rr = std::move(a);
    
    • 1
    • 2

    1.2 左值引用、右值引用本身是左值还是右值?

     被声明出来的左、右值引用都是左值。因为根据C++语言规范,无论是左值引用还是右值引用,它们都被认为是具名对象,具有地址并且可以寻址。因此,在使用引用时,它们被视为左值。用下面的代码验证:

    void check(int&& rr) {
    	cout << "Yes" << endl;
    }
    
    int main() {
    	int a = 5;	       		 // a是个左值
    	int& ref_a_left = a;	   	// ref_a_left是个左值引用,本身是左值
    	int&& ref_a_right = std::move(a); // ref_a_right是个右值引用,本身是左值
    
    	check(a);              // 编译不过,无法将左值绑定到右值
    	check(ref_a_left);     // 编译不过,左值引用ref_a_left本身也是个左值
    	check(ref_a_right);    // 编译不过,右值引用ref_a_right本身也是个左值
    
    	check(std::move(a));			// 编译通过
    	check(std::move(ref_a_right));  // 编译通过
    	check(std::move(ref_a_left));   // 编译通过
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

     右值引用本身是一个左值,那么也就不难理解,为什么右值不可以修改,但是右值引用可以修改:我们并不是修改右值,而是修改右值引用所引用的对象;右值引用是左值,它有自己的标识符和地址。

    int&& rr = 10;
    rr = 20;
    
    • 1
    • 2

    1.3 特殊的 const 左值引用

     const 左值引用比较特殊,它既可以接受左值,也可以接受右值。和右值引用一样, const 左值引用能够延长右值的生命周期,以避免产生悬空引用。下面是cppreference 中的说明:

    在这里插入图片描述

     当我们将一个右值绑定到 const 左值引用上时,编译器会自动创建一个临时对象,并将该右值绑定到这个临时对象上。这个临时对象的生命周期会与 const 左值引用的生命周期相同,从而确保了在 const 左值引用的作用域内能够安全地使用这个右值。
     这也是为什么经常会看到使用 const& 作为函数参数的原因之一。如果没有const,这样的代码就无法编译通过了:v.push_back(5)

    void push_back (const value_type& val);
    
    • 1

    2. 右值引用与移动构造的意义

     左值引用做参数和返回值都可以提高效率。但是左值引用的短板在于,如果引用的对象出作用域销毁,那么就不能使用左值引用了。例如下面的例子中(不考虑编译器优化),hello()函数在返回时,首先会将 "hello world" 拷贝给临时变量。
      这个临时变量本质上属于将亡值,同时具有身份移动性。在没有移动构造前,s只能把临时变量的内容再拷贝复制一份,而眼睁睁的看着临时变量生命周期到了而销毁 —— 白白浪费!

    string hello() {
    	return "hello world";
    }
    string s = hello();
    
    • 1
    • 2
    • 3
    • 4

     但是对于一个这个即将被销毁的对象,我们为什么不聪明点,直接将其中的资源占为已有呢?将对方资源所有权转移过来,这就是移动构造的核心思想。如何转移?可以直观理解为指针做一个交换:

    namespace my{
    	string(string&& s)
    	    :_str(nullptr) ,_size(0), _capacity(0){
            swap(str_, s.str_)        // 所有权转移 
    	}
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

     注意,移动构造后一定要将原对象中的指针置为空,否则一块空间会被 delete 两次。而对于 nullptr, delete 多次也没有影响。

     既然移动构造这么香,那么我们进一步想,哪些资源可以被移动构造?C++的设计者们注意到,大多数情况下,右值所包含的对象都是可以安全的被移动的。

     那么问题来了,我们如何显式告诉编译器我们要接收一个右值呢?在C++11之前,只有 const 修饰的左值引用才能接收右值,这可是个大问题啊,你都被const修饰了,我还怎么“偷”你的资源呢?顺着这个逻辑思考,右值引用的出现也是一个必然,它为移动构造的出现洒下了肥沃的土壤。

     接下来我们来手动实现下移动构造函数

    3. 移动构造函数的使用

    namespace my {
    	class string {
    	public:
    		string() : len_(0), cap_(0), data_(nullptr) {}
    
    		string(const char* s) {
    			// 略
    		}
    
    		string(const my::string& s) {
    			cout << "拷贝构造函数" << endl;
    			// 略
    		}
    		
    		my::string& operator=(const my::string& s) noexcept {
    			cout << "赋值运算符重载" << endl;
    			// 略
    		}
    		
    		string(my::string&& s) {
    			cout << "移动构造函数" << endl;
    			len_ = s.len_;
    			cap_ = s.cap_;
    			swap(data_, s.data_);
    		}
    
    		my::string& operator=(my::string&& s) noexcept{
    			cout << "移动赋值运算符重载" << endl;
    			len_ = s.len_;
    			cap_ = s.cap_;
    			if (data_) {
    				delete data_;
    				data_ = nullptr;
    			}
    			swap(data_, s.data_);
    			return *this;
    		}
    
    	private:
    		char* data_;
    		int len_;
    		int cap_;
    	};
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    STL库中基本都支持了移动构造和移动赋值,例如string等等

    string (string&& str) noexcept;
    string& operator= (string&& str) noexcept;
    
    • 1
    • 2

    swap函数也是
    在这里插入图片描述
     给大家分享一个我初学时容易犯的错误:我们仍然沿用上面实现的 my::string类做测试,大家觉得 s = t.s 中有没有调用移动赋值呢?

    class test {
    	public:
    		test() {}
    		// ……
    		test(test&& t) {
    			s = t.s;
    		}
    
    	private:
    		my::string s;
    	};
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在这里插入图片描述
    答案是并没有。虽然t是一个右值,但是t.m确实是一个左值:
    在这里插入图片描述

    4. move的实现原理

     刚开始学习 std::move,大家总是容易对move函数抱有误解,认为move函数完成了内存上资源的移动,然而实际上move完成的工作只是强制类型转换,我们来看看相应的源码:

    template <typename T>
    typename remove_reference<T>::type&& move(T&& t)
    {
        return static_case<typename remove_reference<T>::type&&>(t);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

     虽然细节上我们并不了解,但我们大致可以看出 move 就是完成了强制类型转换的工作。为了更加透彻的理解,我会为大家说明其中的细节

    • 在模板中,&& 并不是代表右值引用,而是万能引用。它既可以接收左值,也可以接收右值。

    • 这个返回值 typename remove_reference::type&& 是什么意思呢?type是定义在 remove_reference 中的类型成员,因此访问它时也与访问静态成员一样用::访问,而该类是一个模板类,所以在它前面要加typename关键字。

    • remove_reference 从它的名字也可以看出,它是通过模板去除引用
      在这里插入图片描述

    • 我们假定T为int&,即传入左值,那么最后可以将上面的代码简化成如下的形式:

      int && move(int& && t){
          return static_case<int&&>(t);
      }
      
      • 1
      • 2
      • 3
    • 遇到 int& && 的时候,会发生引用折叠,折叠的规则如下图所示:
      在这里插入图片描述

    • 所以最终move其实就做了这么一件事:

      int && move(int& t){
          return static_case<int&&>(t);
      }
      
      • 1
      • 2
      • 3

    5. 完美转发

     我们前面谈到,在模板中,&& 既可以接收左值,也可以接收右值,但是当我们在函数内部,将val 传递给另一个函数的时候,val将发生退化。此时,val总是是被当作左值进行传递的。

    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<typename T>
    void PerfectForward(T&& t)
    {
    	Fun(t);
    }
    
    int main()
    {
    	PerfectForward(10); // 右值
    	int a = 10;
    	PerfectForward(a); // 左值
    	PerfectForward(std::move(a)); // 右值
    	const int b = 8;
    	PerfectForward(b); // const 左值
    	PerfectForward(std::move(b)); // const 右值
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    在这里插入图片描述

    • 为了保持参数原有的左右值属性,我们需要使用std::forward<模板参数>()函数来实现完美转发:
      在这里插入图片描述

    • 注意!为了保持参数原有的左右值属性,所有的向下转发都需要实现完美转发:

  • 相关阅读:
    中间件漏洞 | Apache-路径穿越/任意命令执行
    零信任架构分析【扬帆】
    ava单例模式之总有你想不到的知识
    JVM(一):jvm中的数据结构(内存模型):Java Virtual Machine Specification Runtime Data Areas
    STL源码阅读笔记-内存空间的分配和回收
    调用CFCA金信反欺诈服务相关接口,很详细
    rac节点停止和启动
    pip使用豆瓣镜像源
    python常见的运算符及用法
    中间件redis的使用
  • 原文地址:https://blog.csdn.net/whc18858/article/details/132918508