何谓右值
一个最简单判断左值、右值的方式是:等号左边的值即左值,等号右边的值即右值;但为了更加严谨,我们这样定义:能够取地址的,有名字的就是左值;反之,不能取地址,没有名字的就是右值。
右值引用
右值引用简单理解,就是绑定到左值的引用,右值引用的特点是:它将让其所绑定的右值重获新生,即使该右值本身是一个临时变量,但是它本身却不能绑定任何左值。比如:
int c;
int && d = c; //错误,右值不能绑定左值
T && a = ReturnRvalue(); //#1
T b = ReturnRvalue(); //#2
在#1中,我们声明了一个名为a的右值引用,其值等于ReturnRvalue()返回的临时变量的值。而#2,b则是通过函数返回的临时量再进行构造生成的值。
需要注意的是,上述代码的前提是:ReturnRvalue()函数本身返回的就是一个右值,所以ReturnRvalue必须类似如下形式:
T && ReturnRvalue()
{
return T();
}
对于返回右值引用的函数来说,支持右值声明的绑定,不支持非常量左值,却支持非常量左值。具体可以参考下图:
从上图中,我们也可以知道,常量右值引用没有用途。
代码片段如下:
T &e = ReturnRvalue(); //#1
const T & f = ReturnRvalue(); //#2
如上所示,#1将会编译报错,#2却能编译通过,这个主要是由于c++98标准的定义问题,这里不再讨论该细节,我们只需要知道即可。
右值引用除了作为返回值,作为参数也是可以的,如下:
void AcceptRvalueRef(Copyable && s)
{
...
}
和左值引用相同,我们也不需要进行额外拷贝。
右值引用与其他对比
上面的思维导图主要对比了右值引用在作为参数及返回值时与其他类型的不同,可以得出以下几点:
- 右值引用无论作为参数还是返回值,都可以使用临时变量,并且由于其可以窃取临时变量中的内存,导致其效率较高;
- 常量左值引用是万能类型,当参数是常量左值时,我们传入右值也可以;当返回值是右值时,使用常量左值也可以接收。
- 左值引用无论是作为参数还是返回值,都要求其不能使用临时变量。
当右值引用作为构造函数参数时,这就是所谓的移动构造函数,也就是所谓的移动语义。
右值引用与移动语义
我们知道,当类成员中存在指针成员时,使用复制拷贝构造,需要进行深拷贝,但问题在于:我们真的任何时候都需要深拷贝吗?首先来看一下下面的几个代码片段。
片段1:
String s;
String p = s;
...
上面无疑是需要深拷贝的,因为无论s,还是p,都可能在我们后面的代码里面继续用到。
片段2:
String GetTemp() {return String();}
int main()
{
String str = GetTemp();
}
这里代码中实际只用到了str,但是实际上却调用了一次构造(GetTemp函数中调用String构造生成临时对象)、两次拷贝构造(一次是GetTemp函数调用拷贝构造生成临时对象用于返回、一次是str接收)、三次析构。这里拷贝构造调用了两次深拷贝,但是最后实际使用到的对象却只有str,因此,可以看出,这里有一次深拷贝是多余的。
当堆内存很大时,多余的深拷贝以及其对象的堆内存析构耗时就会变的很可观,那么是否有一种方式,可以让函数中的返回的临时对象空间是否可以不析构,而可以重用呢?
基于上述原因,因此c++11提供了移动构造来解决上述问题。移动构造也是基于右值引用来实现的。
针对上面片段2存在的问题,可以使用移动构造进行解决,下面是移动构造的示例:
class HasPtrMem{
public:
HasPtrMem():d(new int(3)){
...
}
HasPtrMem(const HasPtrMem& h) : d(new int(*h.d)){
}
HasPtrMen(HasPtrMem && h) : d(h.d){ //#1
h.d = nullptr;
}
...
int *d;
}
HasPtrMen GetTemp() {
HatPtrMem h;
return h;
}
int main()
{
HasPtrMem a = GetTemp();
...
}
#1所示的即移动构造函数,它与拷贝构造函数不同的是,它接收的是一个右值引用的参数,即HasPtrMem && h,移动构造函数使用参数h的成员d初始化了本对象的成员d初始化了本对象的成员d(而不是像构造函数一样需要分配内存,然后再将内容一次拷贝到新分配的内存中),而h的成员d随后就被置空。
这里的“偷”堆内存,就是指将对象d指向h.d所指的内存这一条,除此之外,我们还要讲h的d置为空指针,这是因为再移动构造以后,临时对象会被析构,如果不改变h.d的指向的话,那么我们“偷”来的堆内存也被析构掉了。
那么移动构造函数什么时候才会被触发呢?事实上,我们也提供了拷贝构造函数,从外部调用形式来看,拷贝构造及移动构造调用没有分别,那么怎么确保我们调用的是移动构造呢?这就涉及到临时对象的问题。
右值引用与std::move
std::move主要用于将左值强行转换为右值,需要注意的是,被转化的左值生命周期并没有因这种转换而改变。但是在使用std::move时,我们却需要注意:一旦该左值被转换为右值,如果和移动语义结合使用,那么该左值的生命周期就将结束,如果此后还继续使用改左值,那么就会出现严重错误。如下所示:
#include <iostream>
using namespace std;
classHugeMen{
public:
HugeMem(int size) : sz(size > 0 size : 1){
c = new int(sz);
}
HugeMem(HugeMem && hm) : sz(hm.sz) , c(hm.c){
hm.c = nullptr;
}
int sz;
int *c;
}
int main()
{
HugeMem a;
HugeMem c(move(a));
cout << *a.c << endl;
}
如上式中,a由于移动语义,其堆内存实际已被释放,后面继续调用,那么就会报错。
基于此,所以我们应该注意:应当确保使用std::move用于移动语义的变量是一个临时量。下面是把std::move用于移动语义的正确姿势:
Class Moveable{
public:
...
Moveable(Moveable &&m) :
i(m.i) ,h(move(m.h)){ //#1
m.i = nullptr;
}
int *i;
HugeMem h;
}
Moveable a(GetTemp());
分析上述代码可以发现,GetTemp()临时对象将很快析构,可以避免出现错误。
这里考虑一下,如果#1所在的地方HugeMem不支持移动语义怎么办,这也没多大问题,因为此时会调用其常量左值拷贝函数(上文中已经说明了常量左值是接收右值的),因此也不会有多大问题。基于此,因此我们在编写移动构造函数时应总是将拥有堆内存、文件句柄的资源从左值转换为右值。
移动语义与std::move
移动语义与std::move结合时,要格外注意不要误用,下面是一个错误使用的示例:
int main()
{
Moveable a;
Moveable c(move(a));
cout << *a.i << endl;
return 0;
}
a本身是一个左值,但是被move强转为右值,但是a的生命周期又还没有结束,根据上述移动语义的说明,我们可知:a指向i的内存已经被c窃取了,a.i指针指向空,那么一旦输出i的值,那么程序就会出现错误。
从上面示例我们可以得到一个注意事项,即:我们在使用move语义时,一定要确保被强转的左值很快会被析构,否则就会带来隐患。
下面提供一种移动语义与std::move的正确用法:
class HugeMem{
public:
HugeMem(int size) : sz(size > 0 ? size : 1){
c = new int[sz];
}
~HugeMem() {delete[] c;}
HugeMem(HugeMem && hm) : sz(hm.sz),c(hm.c){
hm.c = nullptr;
}
int *c;
int sz;
};
class Moveable{
public:
Moveable() : i(new int(3)),h(1024){}
~Moveable() {delete i;}
Moveable(Moveable && m) :
i(m.i),h(move(m.h)){
m.i = nullptr;
}
int *i;
HugeMem h;
};
Moveable GetTemp(){return Moveable();}
int main()
{
Moveable a(GetTemp());
return 0;
}
Moveable对象中包含了HugeMem对象,HugeMem类中存在堆内存的分配的成员,如果Moveable传入了一个临时HugeMem对象,那么毫无疑问HugeMem启用移动语义,同时我们同时可以使用std::move将HugeMem的成员转换为右值,从而对该成员也启用移动语义(前提是该成员要存在移动构造函数)。
移动语义注意事项
- 移动构造函数中要避免使用const右值引用,因为我们最终是要修改右值引用中堆内存指向的。
- C++11中,实际拷贝/移动构造函数有以下三个版本:
T Object(T &)
T Object(const T &)
T Object(T &&)
一般来说,编译器会隐式的生成一个移动构造函数,不过如果我们自己声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或多个,那么编译器都不会再生成默认版本。默认版本的移动构造一般也是按位拷贝,这对实现移动语义来说是不够的,通常情况下,如果要实现移动语义,都需要我们自定义移动构造函数。当然,如果类中不包含堆内存,实不实现移动语义都不重要。
考虑到常量的左值引用是万能的,假设我们传入参数类型为右值,但是又没有实现移动语义会怎么样呢?那么就会进入常量拷贝构造函数,这就确保了即使移动构造不成,还可以拷贝。
移动语义与swap
移动语义可以实现高效的swap函数,如下:
template<class T>
void swap(T& a,T& b)
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
上述代码完全避免了资源的释放与申请,从而完成高效置换。
完美转发
所谓完美转发,就是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另一个函数。由于拷贝问题的存在,所以完美转发一般不包括值传递。如下:
template<typename T>
void IamForwording(T t){ IrunCodeActually(t); }
针对上面的例子来说,就是希望:IamForwording传入左值对象,那么IrunCodeActually就获得左值对象;IamForwording传入右值对象,那么IrunCodeActually就获得右值对象。换一句话说,假定IrunCodeActuall有重载左值有右值版本,那么IamForwording传左值就进入IrunCodeActuall左值版本,传右值就进入IrunCodeActuall右值版本。我们当然可以实现两个版本的IamForwording转发函数以对应IrunCodeActuall,但是这样过于繁杂,所以我们更期望实现IamForwording转发函数的模板函数。
虽然貌似很简单,但实际并没有我们想象的那么简单,比如当转发函数为右值,而模板参数为左值,那么我们面临的第一个问题就是,如何确定转发函数提供的实际类型。代码如下:
template<typename T>
void IamForwording(T &&t){ IrunCodeActually(t); }
T a;
IamForwording(a); //a是左值,而转发函数参数又是右值,此时目标函数IrunCodeActually中的是左值还是右值?
基于上述原因,所以c++11提供了引用折叠,引用折叠一方面确定了左值右值类型叠加时的类型确定规则,另一方面该规则确保了转发者与接收者的类型一致。
我们可以用两条语句来抽象表示转发者与接收者的参数类型叠加问题:
typedef T& TR;
TR& v;
如上述代码所示,T、TR的类型可能是左值,也可能右值,那么v最后是左值还是右值呢?针对此,C++11定义了以下的引用折叠规则:
我们可以把TR认为是转发函数参数类型,v为接收函数类型,v的实际类型为叠加后的类型。从上表可以看出一旦定义中出现了左值引用,那么引用这得优先将其折叠为左值引用。
从上表也可以看出,除了第5种(TR 为 T&&,v为TR&,而v实际为A&)类型外,其他都是不需要进行额外转发就能够确保模板参数类型TR和目标函数参数类型v一致。可见引用折叠规则独自无法完成完美转发。因此,C++11在此基础上又提出了std::forward。
分析上表第5种情况可以看出,实际类型为A&,但是我们传入的是T&&,要确保目标函数也收到T&&,那么就只能A&转换为T&&,很明显,这是左右值的转换,我们很自然想起了std::move,但是c++11为了在功能上区别完美转发,所以使用std::forward取代std::move。
完美转发主要用于函数模板中,下面是完美转发示例:
#include <iostream>
#include <list>
using namespace std;
void RunCode(int && m) {}
void RunCode(int &m) {}
void RunCode(const int && m) {}
void RunCode(const int & m) {}
template<typename T>
void PerfectForward(T &&t){RunCode(forward<T>(t));}
int main()
{
int a;
int b;
const int c = 1;
const int d = 0;
PerfectForward(a); // lvalue ref
PerfectForward(move(b)); // rvalue ref
PerfectForward(c); // const lvalue ref
PerfectForward(move(d)); // const rvalue ref
}
从上面代码种可以看到,当模板类型为左值时,其进入了目标函数的左值版本,当模板类型为右值时,其进入了目标函数的右值版本,转发函数可以视作不存在,这就是完美转发。
相关代码链接:https://github.com/KevinCoders/MyStudy/blob/master/base/right_value.cpp