C++——右值引用和move语义解析

C++——右值引用和move语义的理解

文章目录

  • C++——右值引用和move语义的理解
    • 1.前言
    • 2.左值和右值
    • 3.左值引用
    • 4.右值引用
    • 5.move(移动)语义
    • 6.总结
    • 7.参考资料

1.前言

​ 在C++学习过程中,C++11的一个新特性——右值引用引起了我的关注,而且在此之前我对于C++中的左值和右值的概念比较模糊,因此在理解左值和右值的基础上,我对C++中的右值引用以及move语义进行了学习研究。

2.左值和右值

**左值 (lvalues):**指可以放在赋值号左边,可以被赋值的值;左值必须要在内存中有实体;所有的变量,包括常变量,都是左值。

**右值 (rvalues):**右值当在赋值号右边取出值赋给其他变量的值;右值可以在内存也可以在CPU寄存器。

​ 一个对象(变量或表达式)被用作右值时,使用的是它的内容(值),右值不能取其地址;被当作左值时,使用的是它的地址。

举个例子:

int x = 1 + 2;  //x为左值,1+2为右值
int a;  //变量a
int b;  //变量b
a = b;  //此时a为左值,b为右值

​ 在这个例子中,将b的值赋值给a,将值保存在a的内存中,b在这里面是右值,a在这里面是左值
因为b作为实例既可以当做左值也可以当做右值。

总结:

  • 在一般情况下,需要右值的地方可以用左值来代替,需要左值的地方必须使用左值。
  • 左值存放在实例中,有持久的状态,而右值是字面常量,或是在表达式求值过程中创建的临时实例,没有持久的状态。我们可以将左值看作为容器(container)而将右值看做容器中的事物。如果容器消失了,容器中的事物也就自然就无法存在了。

3.左值引用

​ 引用是C++语法做的优化,引用的本质还是靠指针来实现的。引用相当于变量的别名。引用可以改变指针的指向,还可以改变指针所指向的值。声明引用的时候必须初始化,且一旦绑定,不可把引用绑定到其他对象;对引用的一切操作,就相当于对原对象的操作。

​ 左值引用是常见的引用,C++中可以使用“&”符号定义引用,如果一个左值同时也是引用,那么就称其为“左值引用”。如以下代码:

int y = 10;
int & yref = y;   //声明yref为y的引用并初始化, yref为左值引用
yref = 20;     //通过引用改变y的值 

这里将yref声明为类型int&:一个对y的引用,它被称作左值引用,可以通过该引用改变y的值。

通过反汇编进一步分析上面的代码:

int y = 10;
//这条mov指令把10放到y的内存中
00354218  mov  dword ptr [y],0Ah  
    
int & yref = y;
// 下面的lea指令把y的地址放入eax寄存器, mov指令把eax的内容放入yref内存里面
0035421F  lea  eax,[y]  
00354222  mov  dword ptr [yref],eax 
    
yref = 20;
/* 下面的mov指令把yref内存的值放入eax寄存器(就是y的地址)
   mov指令再把20放入eax记录的地址的内存里面(就是把20赋值给y)
*/
00354225  mov  eax,dword ptr [yref]  
00354228  mov  dword ptr [eax],14h

仔细比较可以发现,定义一个左值引用和定义一个指针在反汇编中是没有任何区别的。

非const左值引用不能使用右值对其赋值:

int & ref = 10;  //error! 不能用右值对非const左值赋值

这里在右边是有一个临时值 10 ,它是一个需要被存储在一个左值中的右值。左边有一个引用(一个左值),它应该指向一个已经存在的对象,但是10 是一个数字常量,也就是一个右值,不能取其地址。如果该值想要被引用,需要先变成一个左值,但从右值到左值的转换是被禁止的(如果被允许,就可以通过它的引用来改变数字常量的值,无意义),因此编辑器会报错:

error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'

可以将一个const的左值(不可被修改)绑定到一个右值上,所以下面的代码可以成功运行:

const int & ref = 10;   //ok

但上述代码不可以认为加了const之后10就可以取地址了,而是因为10产生了一个临时变量,临时变量给b进行赋值的,相当于下面的操作:

//这里temp是在内存上产生的临时变量
const int temp = 10; 
const int &ref = temp;

通过反汇编可以进行进一步确认:

const int & ref = 10;
010517C8  mov   dword ptr [ebp-0Ah],0Ah //ebp-0Ah就是内存栈上产生的临时量的内存地址
010517CF  lea   eax,[ebp-0Ah]   //取临时量的内存地址放入寄存器eax
010517D2  mov   dword ptr [ref],eax  //再把eax寄存器的值(放的是临时量地址)存入ref中

总结:**左值引用必须要求右边的表达式能够取地址,如果无法取地址,就需要用常引用,但这样以来,就无法对右边表达式的值进行修改。**解决的办法就是采用右值引用。

4.右值引用

​ 在C++中总会有一些临时的、生命周期较短的值(右值),这些值我们无法改变。但c++引用了右值引用的概念:它是一个可以被绑定到临时对象的类型,允许改变临时对象的值。并且右值引用的对象,不会在其它地方使用。

使用语法:类型 + && + 表达式

type-id && cast-expression 

测试了下面的代码及其反汇编:

int && ref = 10;  // 通过指令可以看到,const int &ref=10 和 int &&ref=10 是一样的
// 这里mov指令相当于是产生了临时量,起始地址ebp-0Ah
00CA18B8  mov   dword ptr [ebp-0Ah],0Ah  
// 把临时量的地址放入eax寄存器当中
00CA18BF  lea   eax,[ebp-0Ah]  
// 再把eax的值(临时量的地址)放入ref内存中(一个指针大小的内存)
00CA18C2  mov   dword ptr [ref],eax  

ref = 40;
00CA18C5  mov   eax,dword ptr [ref]  
00CA18C8  mov   dword ptr [eax],28h 

通过上述的反汇编代码我们可以得出:const int &ref =10;和 int &&ref =10;在底层是没有任何区别的,只是int &&ref = 10可以对数据进行修改。

以下一段代码有助于更加深刻的理解右值引用:

	int a = 10;
	int &b = a;
	//int &&c = a; // 错误,无法将左值a绑定到右值引用c
	//int &&d = b; // 错误,无法将左值b绑定到右值引用d
	int &&e = 20;  // 正确,20是一个右值(没地址没名字),可以绑定到右值引用e上
	//int &&f = e; // 错误,无法将左值e绑定到右值引用f,因为e有名字,有地址,本身也是左值
	int &g = e;    // 正确,e本身有名字,有地址,是一个左值,可以被g引用

C++11标准引入右值引用这一概念是为了实现move语义、完美转发等目标,在研究这些概念之前,需要先了解两个规则:

(1)引用折叠规则

​ 对于C++语言,不可以在源程序中直接对引用类型再施加引用。T& &将编译报错。C++11标准中仍然禁止上述显式对引用类型再施加引用,但如果在上下文环境中(包括模板实例化、typedef、auto类型推断等)如出现了对引用类型再施加引用,则施行引用折叠规则:

  • T& &变为T&
  • T& &&变为T&
  • T&& &变为T&
  • T&& &&变为T&

(2)模板参数类型推导规则

​ 对函数模板templatevoid foo(T&&);,应用上述引用折叠规则,可推导出如下结论:

  • 如果实参是类型A的左值,则模板参数T的类型为A&,形参类型为A&;
  • 如果实参是类型A的右值,则模板参数T的类型为A&&,形参类型为A&&。

这还适用于类模板的成员函数模板的类型推导:

template  class vector {
public: 
    //T是类模板参数:该成员函数不需要类型推导;这里的函数参数类型就是T的右值引用
    void push_back(T&& x); 
    //该成员函数是个函数模板,有自己的模板参数,需要类型推导
     template  void emplace_back(Args&& args);
};

函数模板的形参必须是T&&形式,才需要模板参数类型推导。即使形参声明为const T&&形式,就只能按字面意义使用,不需要模板参数类型推导:

templatevoid f(const T&& param); // 这里的“&&”不需要类型推导,意味着“常量类型T的右值引用”
templatevoid f(std::vector&& param);  // 这里的“&&”不需要类型推导,意味着std::vector的右值引用

5.move(移动)语义

​ move语义是和拷贝语句相对的,是一个最佳移动资源的方法,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高C++应用程序的性能。可以类比文件的剪切和拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。move语义的实现依赖于右值引用,允许程序员编写将资源(例如动态分配的内存)从一个对象传输到另一个对象的代码,move语义行之有效,因为它允许从程序中其他地方无法引用的临时对象转移资源,即临时对象中的资源能够转移其他的对象里。为了实现move语义,需要在类中提供一个动态构造函数(移动构造函数),和可选择的动态赋值运算符(移动赋值操作符)。对于右值的拷贝和赋值会调用移动构造函数和移动赋值操作符。

通过如下例子分析move语义对于提升效率的作用。

class Holder
{
  public:
    Holder(int size)         // Constructor
    {
      m_data = new int[size];
      m_size = size;
    }
    ~Holder()                // Destructor
    {
      delete[] m_data;
    }
  private:
    int*   m_data;
    size_t m_size;
};

这是一个处理动态内存块的类,由于该类对象的数据是由指针管理的堆,系统默认的函数对于处理动态资源是完全不够的。需要自己定义复制构造函数以及重载赋值运算符。首先定义可以实现深拷贝的复制构造函数以及重载赋值运算符:

//复制构造函数,用于初始化
Holder(const Holder& other)
{
  m_data = new int[other.m_size];  
  std::copy(other.m_data, other.m_data + other.m_size, m_data);  
  m_size = other.m_size;
}
//重载赋值运算符,用于赋值而非初始化
Holder& operator=(const Holder& other) 
{
  if(this == &other) return *this; 
  delete[] m_data;  
  m_data = new int[other.m_size];
  std::copy(other.m_data, other.m_data + other.m_size, m_data);
  m_size = other.m_size;
  return *this;  
}

这里我使用一个已经存在的对象other来初始化或者赋值给一个新的Holder对象,创建了一个同样大小的数组并且将other里面m_data的数据拷贝到this.m_data中,即实现深拷贝。然后可以调用这两个函数从另一个已经存在的对象来构造新的对象或将一个已存在的对象替换为另一个已存在的对象:

Holder h1(10000); // 构造函数
Holder h2 = h1;   // 复制构造函数 (1)
Holder h3(h1);    // 复制构造函数 (2)
Holder h4(20000); // 构造函数
h1 = h4;          // 赋值运算符 (3)

对于上述代码中的这些全局对象来说,通过复制构造函数和赋值运算符实现深拷贝(生成一个它们所属类的一个副本)是有必要的,因为我们可能在整个程序中还要用到这些对象,它们不会很快被销毁。但是对于一些临时对象来说,比如当一个函数的返回值为一个对象时,需要创建临时对象并调用复制构造函数,造成很大的系统开销。如以下代码:

Holder createHolder(int size)
{
  return Holder(size);
}

它用传值的方式返回了一个Holder对象。我们知道,当函数返回一个值时,编译器会创建一个临时且完整的对象(右值)。由于Holder类有着内部的内存分配,以现有的类设计返回这些东西的值会导致多次内存分配,如下:

int main()
{
  Holder h = createHolder(1000);
}

createHolder()创建的临时对象(是一个右值,调用结束后就会消亡)被传入复制构造函数中,根据我们现有的设计,拷贝构造函数通过拷贝临时对象的数据分配了它自己的m_data指针。这里有两次内存分配:

  • 创建临时对象
  • 拷贝构造函数调用

同样地,在赋值操作符中也会有复制过程:

int main()
{
  Holder h = createHolder(1000); // 复制构造函数
  h = createHolder(500);         // 赋值操作符
}

赋值运算符清除了对象的内存,然后通过从临时对象中复制数据,为赋值的对象从头开始分配新的内存。在这里也有两次内存分配:

  • 临时对象创建
  • 调用赋值运算符

以上代码复制的过程太多,我们希望避开这些复制过程,直接实现对象的传递,改变资源的拥有者,但上面提供的复制构造函数和重载的赋值运算符的参数都是常量引用,无法修改其值,而非常量引用的初始值又必须为左值,也无法实现我们的要求。这时便可以通过使用基于右值引用的move语义来实现。因此需要提供新的版本的拷贝构造函数和赋值运算符,即移动构造函数和移动赋值运算符:

//移动构造函数
Holder(Holder&& other)     // 右值引用为函数形参
{
  m_data = other.m_data;   // (1)
  m_size = other.m_size;
  other.m_data = nullptr;  // (2)
  other.m_size = 0;
}
//移动赋值运算符
Holder& operator=(Holder&& other)    // 右值引用为函数形参  
{  
  if (this == &other) return *this;
  delete[] m_data;         // (1)
  m_data = other.m_data;   // (2)
  m_size = other.m_size;
  other.m_data = nullptr;  // (3)
  other.m_size = 0;
  return *this;
}

在移动构造函数中,使用一个右值引用来构造Holder对象,其关键是:作为一个右值引用,我们可以修改它,所以可以先偷他的数据(1),然后将它设置为一个右值nullptr(2)。这里没有深拷贝,我们仅仅移动了这些资源。**注意:将右值引用的数据设置为nullptr是很重要的,因为一旦临时对象走出其作用域,它就会调用析构函数中的delete[] m_data。**通常来说,为了让代码看上去更加的整洁,最好让被偷取的对象的数据处于一个良好定义的状态。类似地,对于移动赋值运算符,我们先清理已有对象的数据(1),再从其它对象处偷取数据(2)。当然还要把临时对象的数据设置为正确的状态(3)。剩下的就是常规的赋值运算所做的操作。

​ 有了这两个函数之后,编译器就会检测到底是在使用临时对象(右值)创建一个对象还是使用常规的对象(左值),并且它会根据检测的结果触发更加合适的构造函数(或者运算符)。例如:

int main()
{
  Holder h1(1000);                // 调用构造函数
  Holder h2(h1);                  // 调用复制构造函数(左值h1是输入)
  Holder h3 = createHolder(2000); // 调用移动构造函数,输入是一个右值(临时对象) 

  h2 = h3;                        // 调用赋值运算符(输入h3是一个左值)
  h2 = createHolder(500);         // 调用移动赋值运算符,输入是一个右值(临时对象)
}

注意:在运行上面的代码时,有时移动构造函数在(1)处并没有被调用,仅构造函数被调用了。这是因为一个被称作*Return Value Optimization(RVO)*的优化方法,编译器能够检测出返回了一个对象的值,并且为此应用一种返回的快捷方式来避免无意义的拷贝。可以让编译器不使用这个优化。但RVO仅仅针对返回值(输出),不包括函数参数(输入)。有许多地方我们会将可移动的对象作为输入参数传入函数,这时候就是移动构造函数和移动赋值运算符发挥作用的时候了。

​ 此外,通过标准库中的工具函数std::move,可以移动左值。它被用来将左值转化为右值,假设我们想要从一个左值盗取数据:

int main()
{
  Holder h1(1000);     // h1是一个左值
  Holder h2(h1);       // 调用复制构造函数(左值h1是输入)
}

由于h2接收了一个左值,复制构造函数被调用。我们需要强制调用移动构造函数从而避免无意义的拷贝,所以可以这样:

int main()
{
  Holder h1(1000);           // h1是一个左值
  Holder h2(std::move(h1));  // 调用移动构造函数
  //Holder h3(std::move(h1)); // 此时h1已无数据,被转移到了h2  (1)
}

这里,std::move将左值h1转化为一个右值:编译器看见输入变成了右值,所以调用了移动构造函数。h2将会在构造时从h1处偷取数据。**注意:**上面的代码中(1)行试图将h1对象的资源移动到h3,但此时h1已无数据,被转移到了h2。因此,跟指针不一样的是,move不会分享任何东西,如果对象A从对象B中偷去了数据,对象B中的数据就不再存在了,因此也就不再合法了。我们知道在处理临时对象时这没有问题,但是在从常规对象身上偷取数据时就需要慎重了。

std::move的作用就是返回传入参数的右值引用,利用上面说到的模板函数类型推导规则,定义在C++标准程序库中,无论输入的实参是左值还是右值,均返回右值引用,从std::move的源码中可以看出:

template
	_NODISCARD constexpr remove_reference_t<_Ty>&&
		move(_Ty&& _Arg) noexcept   
	{	// forward _Arg as movable
	return (static_cast&&>(_Arg));
}

​ 当然,move语义不仅仅适用于类,对于所有需要进行复制的过程,如作为参数传入函数中需要再复制一个作为形式参数,都可以使用move语义避免很多没有意义的复制。如实现这样一个函数——将数组内的元素都乘以2,可以写以下代码:

std::vector twice_vector(std::vector);  // 将数组内所用元素都乘以2然后再返回
std::vector v = { /*很多元素*/ };
std::vector w = twice_vector( v ); 
// v变量将在以后都不会被用到

这段代码发生了多次没有意义的copy,既损失了时间又损失了内存。可以通过move语义来高效实现:

typedef std::vector IntVec;
IntVec twice_vector(IntVec a)
{
  for (auto& e : a)
    e *= 2;
  return std::move(a); //直接将实参数组的数据资源转移给左值,避免临时对象无意义的复制
}
IntVec v = { /*...*/ };
IntVec w = twice_vector( std::move(v) );

6.总结

​ 通过对右值引用以及move语义的学习研究,掌握了右值引用以及move语义的基本原理和基础用法。右值引用就是为了实现移动语义与完美转发所需要而设计出来的新的数据类型。C++作为一种追求执行效率的语言,通过右值引用实现move语义,可以减少不必要的临时对象的创建、拷贝以及销毁,解决了在用临时对象或函数返回值给左值对象赋值时的深拷贝,大幅度提高了C++应用程序的性能。之后我还会更深入地研究move语义等相关概念,包括使用copy-and-swap的更深入的优化,以及完美转发(perfect forwarding)的概念(允许在多个模板和非模板函数之间移动数据,而不需要强类型转换)。

7.参考资料

[1] https://www.jianshu.com/p/31cea1b6ee24

[2] https://blog.csdn.net/u012198575/article/details/83142419

[3] https://blog.csdn.net/wf19930209/article/details/79307873

[4] https://blog.csdn.net/qq_42214953/article/details/88951697

[5]http://zh.wikipedia.org/wiki/%E5%8F%B3%E5%80%BC%E5%BC%95%E7%94%A8

你可能感兴趣的:(笔记,c++,编程语言)