右值引用和移动语意

一、c++中的左值和右值:
C++11中的定义:左值表达式表示的是一个对象的身份(在内存中的位置),而右值表达式表示的是对象的值(内容)。
左值和右值都是针对表达式而言的,左值是持久的,右值是短暂的:左值在表达式结束后仍然存在,右值在表达式结束后会被销毁。
区分左值和右值的方法:看能不能进行取地址操作,若能,则为左值,否则为右值。
注意:在需要右值的地方可以用左值来代替,但是不能把右值当成左值(也就是位置)使用。
例子:
若有如下定义:

int a = 10;  
int b = 20;  
int *p = &a;  
vector<int> T;  
T.push_back(1);  
string s1 = “Hello”;  
string s2 = "World";  
const int &m =1;  

问:
a, b, a+b, a++, ++a, p, *p, T[0], 100, string(“Hello”), s1, s1+s2, m运算结果分别是左值还是右值?
答:
a,b是变量,变量可以看做只有运算对象而没有运算符的表达式,变量表达式都是左值。事实上,变量a,b均是长久的,在生命周期结束才被销毁,且我们能够对a,b进行取地址操作。故a,b均为左值。
a+b是临时变量,在该表达式结束时就被摧毁,且不能对其进行取地址操作,因此a+b为右值。
a++的作用机理是先将a的值拷贝到一个临时变量中,然后将这里临时变量加1,最终返回的是这个临时变量,因此a++运算结果为右值。
++a的作用机理是在原数据a上直接加1,最终返回的是原来的那个对象(只不过值加了1),因此++a为左值。

p表示的是指向a的指针,它也是长久的,并且我们能对其进行取地址操作,得到的是指向a的指针的地址。因此p为左值。
*p与a等价,也为左值。
T[0]返回容器T中第一个元素的引用,这是一个int型变量,是长久的,并且能对其进行取地址操作,因此T[0]为左值。
100是个常量,在使用过后就会销毁,并且不能对其进行取地址操作,因此100为右值。
string(“Hello”)与100类似,也是个常量,在使用过后就会销毁,并且不能对其进行取地址操作,因此string(“Hello”)为右值。
s1是string类型的变量,与a,b类似,是长久的,并且可以进行取地址操作。因此s1是左值。
s1+s2与a+b类似,是临时变量,在表达式结束就被摧毁,并且不能对其进行取地址操作。因此s1+s2是右值。
m是一个int类型的const左值引用,但它本身是一个变量表达式,因此m是左值。

二、左值引用和右值引用
左值引用符:&
右值引用符:&&
左值引用不能绑定到右值对象上,右值引用也不能绑定到左值对象上。
由于右值引用只能绑定到右值对象上,而右值对象又是短暂的、即将销毁的。也就是说右值引用有一个重要性质:只能绑定到即将销毁的对象上。
左值、右值引用的几个例子:

int i = 42; //如前所述,i是一个左值对象  
int &lr1 = i; //正确,左值引用绑定到左值对象i  
int &&rr1 = i; //错误,右值引用绑定左值对象  
int &lr2 = i * 42; //错误,如前所述i*42是临时变量,是右值,
                   //而&lr2是左值引用  
int &&rr2 = i * 42; //正确,右值引用绑定右值对象 

注意:以上绑定规则有一个例外,如果左值引用是const类型的,则其可以绑定到右值对象上。(只有引用的const传递可以传递一个临时对象,因为临时对象都是const属性, 且是不可见的,它短时间存在一个局部域中,所以不能使用指针,只有引用的const传递能够捕捉到这个家伙)

const int& lr3 = i * 42;//正确,我们可以将一个const的引用
                       //绑定到一个右值对象上  

对于一个左值,若想使用其右值引用,我们可以用move函数:

int&& rr3 = std::move(i);//正确,显式使用i的右值引用  

三、右值引用与移动语意:

右值引用是C++11中最重要的新特性之一,它解决了C++中大量的历史遗留问题,使C++标准库的实现在多种场景下消除了不必要的额外开销(如std::vector, std::string),也使得另外一些标准库(如std::unique_ptr, std::function)成为可能。即使你并不直接使用右值引用,也可以通过标准库,间接从这一新特性中受益。为了更好的理解标准库结合右值引用带来的优化,我们有必要了解一下右值引用的重大意义。右值引用的意义通常解释为两大作用:移动语义和完美转发。本文主要讨论移动语义。(完美转发问题:https://blog.csdn.net/qq_38216239/article/details/80815142

移动语义,简单来说解决的是各种情形下对象的资源所有权转移的问题。而在C++11之前,移动语义的缺失是C++饱受诟病的问题之一。C++通过拷贝构造函数和拷贝赋值操作符为类设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须使用先复制、再析构的方式。否则,就需要自己实现移动资源的接口。

为了实现移动语义,首先需要解决的问题是,如何标识对象的资源是可以被移动的呢?这种机制必须以一种最低开销的方式实现,并且对所有的类都有效。C++的设计者们注意到,大多数情况下,右值所包含的对象都是可以安全的被移动的。右值(相对应的还有左值)是从C语言设计时就有的概念,但因为其如此基础,也是一个最常被忽略的概念。不严格的来说,左值对应变量的存储位置,而右值对应变量的值本身。C++中右值可以被赋值给左值或者绑定到引用。类的右值是一个临时对象,如果没有被绑定到引用,在表达式结束时就会被废弃。于是我们可以在右值被废弃之前,移走它的资源进行废物利用,从而避免无意义的复制。被移走资源的右值在废弃时已经成为空壳,析构的开销也会降低。

右值中的数据可以被安全移走这一特性使得右值被用来表达移动语义。以同类型的右值构造对象时,需要以引用形式传入参数。右值引用顾名思义专门用来引用右值,左值引用和右值引用可以被分别重载,这样确保左值和右值分别调用到拷贝和移动的两种语义实现。对于左值,如果我们明确放弃对其资源的所有权,则可以通过std::move()来将其转为右值引用。std::move()实际上是static_cast<T&&>()的简单封装。

右值引用至少可以解决以下场景中的移动语义缺失问题:按值传入参数按值传参是最符合人类思维的方式。基本的思路是,如果传入参数是为了将资源交给函数接受者,就应该按值传参。同时,按值传参可以兼容任何的cv-qualified左值、右值,是兼容性最好的方式。

class People {
public:
  People(string name) //按值传入字符串,可接收左值、右值。接收左值时为复制,接收右值时为移动
  :name_(move(name)) {} // 显式移动构造,将传入的字符串移入成员变量
private:  
  string name_;
};
People a("Alice"); // 移动构造name
string bn = "Bob";
People b(bn); // 拷贝构造name

构造a时,调用了一次字符串的构造函数和一次字符串的移动构造函数。如果使用const string& name接收参数,那么会有一次构造函数和一次拷贝构造,以及一次non-trivial的析构。尽管看起来很蛋疼,尽管编译器还有优化,但从语义来说按值传入参数是最优的方式。如果你要在构造函数中接收std::shared_ptr<X>并且存入类的成员(这是非常常见的),那么按值传入更是不二选择。拷贝std::shared_ptr<X>需要线程同步,相比之下移动std::shared_ptr是非常轻松愉快的。按值返回和接收输入参数一样,返回值按值返回也是最符合人类思维的方式。曾经有无数函数为了返回容器而不得不写成这样

void str_split(const string& s, vector<string>* vec); // 一个按值语义定义的字符串拆分函数。
                                             //这里不考虑分隔符,假定分隔符是固定的。

这样要求vec在外部被事先构造,此时尚无从得知vec的大小。即使函数内部有办法预测vec的大小,因为函数并不负责构造vec,很可能仍需要resize。对这样的函数嵌套调用更是痛苦的事情,谁用谁知道啊。有了移动语义,就可以写成这样

vector<string> str_split(const string& s) {
  vector<string> v;
  // ...
  return v; // v是左值,但优先移动,不支持移动时仍可复制。
}

如果函数按值返回,return语句又直接返回了一个栈上的左值对象(输入参数除外)时,标准要求优先调用移动构造函数,如果不符再调用拷贝构造函数。尽管v是左值,仍然会优先采用移动语义,返回vector<string>从此变得云淡风轻。此外,无论移动或是拷贝,可能的情况下仍然适用编译器优化,但语义不受影响。对于std::unique_ptr来说,这简直就是福音。

  unique_ptr<SomeObj> create_obj(/*..*/) 
  {
    unique_ptr<SomeObj> ptr(new SomeObj(/*..*/));
    ptr->foo();  //一些可能的初始化
    return ptr;
  }

当然还有更简单的形式

  unique_ptr<SomeObj> create_obj(/*..*/)
  {
    return unique_ptr<SomeObj>(new SomeObj(/*..*/));
  }

在工厂类中,这样的语义是非常常见的。返回unique_ptr能够明确对所构造对象的所有权转移,特别的,这样的工厂类返回值可以被忽略而不会造成内存泄露。上面两种形式分别返回栈上的左值和右值,但都适用移动语义(unique_ptr不支持拷贝)。接收右值表达式没有移动语义时,以表达式的值(例为函数调用)初始化对象或者给对象赋值是这样的:

vector<string> str_split(const string& s);
vector<string> v = str_split("1,2,3"); // 返回的vector用以拷贝构造对象v。
                           //为v申请堆内存,复制数据,然后析构临时对象(释放堆内存)。
vector<string> v2;
v2 = str_split("1,2,3"); // 返回的vector被复制给对象v(拷贝赋值操作符)。
                         //需要先清理v2中原有数据,将临时对象中的数据复制给v2,然后析构临时对象。

注:v的拷贝构造调用有可能被优化掉,尽管如此在语义上仍然是有一次拷贝操作。同样的代码,在支持移动语义的世界里就变得更美好了。

vector<string> str_split(const string& s);
vector<string> v = str_split("1,2,3"); // 返回的vector用以移动构造对象v。
                           //v直接取走临时对象的堆上内存,无需新申请。之后临时对象成为空壳,不再拥有任何资源,析构时也无需释放堆内存。
vector<string> v2;
v2 = str_split("1,2,3"); // 返回的vector被移动给对象v(移动赋值操作符)。先释放v2原有数据,然后直接从返回值中取走数据,然后返回值被析构。

注:v的移动构造调用有可能被优化掉,尽管如此在语义上仍然是有一次移动操作。不用多说也知道上面的形式是多么常用和自然。而且这里完全没有任何对右值引用的显式使用,性能提升却默默的实现了。对象存入容器这个问题和前面的构造函数传参是类似的。不同的是这里是按两种引用分别传参。参见std::vector的push_back函数。

void push_back( const T& value ); // (1)
void push_back( T&& value ); // (2)

不用多说自然是左值调用1右值调用2。如果你要往容器内放入超大对象,那么版本2自然是不2选择。

vector<vector<string>> vv;
vector<string> v = {"123", "456"};
v.push_back("789"); // 临时构造的string类型右值被移动进容器v
vv.push_back(move(v)); // 显式将v移动进vv

std::vector的增长又一个隐蔽的优化。当vector的存储容量需要增长时,通常会重新申请一块内存,并把原来的内容一个个复制过去并删除。对,复制并删除,改用移动就够了。对于像vector这样的容器,如果频繁插入造成存储容量不可避免的增长时,移动语义可以带来悄无声息而且美好的优化。std::unique_ptr放入容器曾经,由于vector增长时会复制对象,像std::unique_ptr这样不可复制的对象是无法放入容器的。但实际上vector并不复制对象,而只是“移动”对象。所以随着移动语义的引入,std::unique_ptr放入std::vector成为理所当然的事情。容器中存储std::unique_ptr有太多好处。想必每个人都写过这样的代码:

MyObj::MyObj() 
{
  for (/*..*/) 
  {
    vec.push_back(new T());
  }
  //..
}
MyObj::~MyObj() 
{
  for (vector<T*>::iterator iter = vec.begin(); iter != vec.end(); ++iter) 
  {
    if (*iter) delete *iter;
  }
  //..
}

繁琐暂且不说,异常安全也是大问题。使用vector<unique_ptr<T>>,完全无需显式析构,unqiue_ptr自会打理一切。unique_ptr是非常轻量的封装,存储空间等价于裸指针,但安全性强了一个世纪。实际中需要共享所有权的对象(指针)是比较少的,但需要转移所有权是非常常见的情况。auto_ptr的失败就在于其转移所有权的繁琐操作。unique_ptr配合移动语义即可轻松解决所有权传递的问题。注:如果真的需要共享所有权,那么基于引用计数的shared_ptr是一个好的选择。shared_ptr同样可以移动。由于不需要线程同步,移动shared_ptr比复制更轻量。std::thread的传递thread也是一种典型的不可复制的资源,但可以通过移动来传递所有权。同样std::future std::promise std::packaged_task等等这一票多线程类都是不可复制的,也都可以用移动的方式传递。完美转发======除了移动语义,右值引用还解决了C++03中引用语法无法转发右值的问题,实现了完美转发,才使得std::function能有一个优雅的实现。这部分不再展开了。总结======移动语义绝不是语法糖,而是带来了C++的深刻革新。移动语义不仅仅是针对库作者的,任何一个程序员都有必要去了解它。尽管你可能不会去主动为自己的类实现移动语义,但却时时刻刻都在享受移动语义带来的受益。因此这绝不意味着这是一个可有可无的东西。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值