目的
了解对应左值引用, 右值引用,移动语义, 完美转发含义。
右值引用(及其支持的移动语义Move semantics和完美转发Perfect forwarding)是C++11中加入的最重大语言特性之一。
我希望这篇博客能让我或者读者能够明白如下三点, 那么这篇博客就有意义了。
1. 为什么要引入右值引用这个概念呢?
2. 什么是右值引用
3. 右值引用作用
先引入左值, 右值概念:
一般化左值(glvalue): 可寻址的表达式,即可使用&操作符的表达式
纯右值(prvalue): 只读表达式,即不可使用&操作符的表达式,一般为临时值
xvalue: 延长了生命周期的表达式,即右值引用
lvalue :等号左边的值称为左值
rvalue :等号右边的值称为右值
左值右值是指一个表达式(当然这个表达式可以仅是一个简单的变量),区分左值和右值是看表达式能否使用&操作符。
左值引用和右值引用是指左值或者右值的型别,左值的型别可以为左值引用,也可以为右值引用。右值的型别可以为右值引用,但不能为左值引用,因为左值引用仅能引用左值(const修饰的左值引用可以引用右值,因为函数参数重const修饰的参数,编译器会为传入的右值创建临时变量,所以const修饰的左值引用其实是引用的这个临时变量)。右值引用仅能引用纯右值。
为啥要引入右值引用
在C++98中,临时量(术语为右值,因其出现在赋值表达式的右边)可以被传给函数,但只能被接受为const &类型。
这样函数便无法区分传给const &的是真实的右值还是常规变量。而且,由于类型为const &,函数也无法改变所传对象的值。
C++0x将增加一种名为右值引用的新的引用类型,记作typename &&。这种类型可以被接受为非const值,从而允许改变其值。这种改变将允许某些对象创建转移语义。比如,一个std::vector,就其内部实现而言,是一个C式数组的封装。如果需要创建vector临时量或者从函数中返回vector,那就只能通过创建一个新的vector并拷贝所有存于右值中的数据来存储数据。之后这个临时的vector则会被销毁,同时删除其包含的数据。有了右值引用,一个参数为指向某个vector的右值引用的std::vector的转移构造器就能够简单地将该右值中C式数组的指针复制到新的vector,然后将该右值清空。这里没有数组拷贝,并且销毁被清空的右值也不会销毁保存数据的内存。返回vector的函数现在只需要返回一个std::vector<>&&。如果vector没有转移构造器,那么结果会像以前一样:用std::vector<> &参数调用它的拷贝构造器。如果vector确实具有转移构造器,那么转移构造器就会被调用,从而避免大量的内存分配。
eg:
std::move();
bool is_r_value(int &&)
{
return true;
}
bool is_r_value(const int &)
{
return false;
}
void test(int &&i)
{
is_r_value(i); // false
is_r_value(std::move(i)); // true
}
什么是右值引用
最直观理解方法:绑定到右值的引用,哪怕右值是一个临时变量, 只不过让其生命周期变长而已,但是它本身却不能绑定任何左值。
右值引用作用
对于返回右值引用的函数来说,支持右值声明的绑定,不支持非常量左值,却支持非常量左值
1: 右值引用无论作为参数还是返回值,都可以使用临时变量,并且由于其可以窃取临时变量中的内存,导致其效率较高;
2: 常量左值引用是万能类型,当参数是常量左值时,我们传入右值也可以;当返回值是右值时,使用常量左值也可以接收。
3: 左值引用无论是作为参数还是返回值,都要求其不能使用临时变量。
4:当右值引用作为构造函数参数时,这就是所谓的移动构造函数,也就是所谓的移动语义。
当成员存在指针成员,使用复制拷贝构造函数, 需要进行深拷贝。
snippets. 1:
QString s;
QString p = s;
上面无疑是需要深拷贝的,因为无论s,还是p,都可能在我们后面的代码里面继续用到。
snippets. 2:
QString GetTemp() {return QString("Hello World!");}
int main()
{
QString str = GetTemp();
}
这里代码中实际只用到了str,但是实际上却调用了一次构造(GetTemp函数中调用String构造生成临时对象)、两次拷贝构造(一次是GetTemp函数调用拷贝构造生成临时对象用于返回、一次是str接收)、三次析构。这里拷贝构造调用了两次深拷贝,但是最后实际使用到的对象却只有str,因此,可以看出,这里有一次深拷贝是多余的。
当堆内存很大时,多余的深拷贝以及其对象的堆内存析构耗时就会变的很可观,那么是否有一种方式,可以让函数中的返回的临时对象空间是否可以不析构,而可以重用呢?
基于上述原因,因此c++11提供了移动构造来解决上述问题。移动构造也是基于右值引用来实现的。
移动构造函数
class MyClass{
public:
MyClass():d(new int(3)){
}
MyClass(const MyClass& h) : d(new int(*h.d)){
}
######move constructor
MyClass(MyClass&& h) : d(h.d){
h.d = nullptr;
}
private:
int *d = nullptr;
}
MyClass GetTempClass() {
MyClass myclass;
return myclass;
}
int main()
{
MyClass a = GetTempClass();
...
}
######move constructor 表示构造函数, 它与拷贝构造函数不同的是,它接收的是一个右值引用的参数,即MyClass && h,移动构造函数使用参数h的成员d初始化了本对象的成员d初始化了本对象的成员d(而不是像构造函数一样需要分配内存,然后再将内容一次拷贝到新分配的内存中),而h的成员d随后就被置空。
这里的“偷”堆内存,就是指将对象d指向h.d所指的内存这一条,除此之外,我们还要讲h的d置为空指针,这是因为再移动构造以后,临时对象会被析构,如果不改变h.d的指向的话,那么我们“偷”来的堆内存也被析构掉了。
那么移动构造函数什么时候才会被触发呢?事实上,我们也提供了拷贝构造函数,从外部调用形式来看,拷贝构造及移动构造调用没有分别,那么怎么确保我们调用的是移动构造呢?这就涉及到临时对象的问题。这里涉及到移动语义了。
移动语义 std::move
std::move主要用于将左值强行转换为右值,需要注意的是,被转化的左值生命周期并没有因这种转换而改变。但是在使用std::move时,我们却需要注意:一旦该左值被转换为右值,如果和移动语义结合使用,那么该左值的生命周期就将结束,如果此后还继续使用改左值,那么就会出现严重错误。
class MyClass{
public:
MyClass():d(new int(3)){
}
MyClass(const MyClass& h) : d(new int(*h.d)){
}
MyClass(MyClass&& h) : d(h.d){
h.d = nullptr;
}
private:
int *d = nullptr;
}
int main()
{
MyClass a;
MyClass c(move(a));
...
}
如上式中,a由于移动语义,其堆内存实际已被释放,后面继续调用,那么就会报错。
基于此,所以我们应该注意:应当确保使用std::move用于移动语义的变量是一个临时量。下面是把std::move用于移动语义的正确姿势:
Class Moveable {
public:
...
Moveable (Moveable &&m) :
i(m.i) ,h(move(m.h)){ //#1
m.i = nullptr;
}
int *i;
MyClass h;
}
Moveable GetTemp(){return Moveable();}
Moveable a(GetTemp());
分析上述代码可以发现,GetTemp()临时对象将很快析构,可以避免出现错误。
这里考虑一下,如果#1所在的地方HugeMem不支持移动语义怎么办,这也没多大问题,因为此时会调用其常量左值拷贝函数(上文中已经说明了常量左值是接收右值的),因此也不会有多大问题。基于此,因此我们在编写移动构造函数时应总是将拥有堆内存、文件句柄的资源从左值转换为右值。
移动语义与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语义时,一定要确保被强转的左值很快会被析构,否则就会带来隐患。
移动语义注意事项
移动构造函数中要避免使用const右值引用,因为我们最终是要修改右值引用中堆内存指向的。
C++11中,实际拷贝/移动构造函数有以下三个版本:
T Object(T &)
T Object(const T &)
T Object(T &&)
一般来说,编译器会隐式的生成一个移动构造函数,不过如果我们自己声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或多个,那么编译器都不会再生成默认版本。默认版本的移动构造一般也是按位拷贝,这对实现移动语义来说是不够的,通常情况下,如果要实现移动语义,都需要我们自定义移动构造函数。当然,如果类中不包含堆内存,实不实现移动语义都不重要。
考虑到常量的左值引用是万能的,假设我们传入参数类型为右值,但是又没有实现移动语义会怎么样呢?那么就会进入常量拷贝构造函数,这就确保了即使移动构造不成,还可以拷贝。
移动语义的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); }
T a;
IamForwording(a);//a是左值,而转发函数参数又是右值,此时目标函数IrunCodeActually中的是左值还是右值?
基于上述原因,所以c++11提供了引用折叠,引用折叠一方面确定了左值右值类型叠加时的类型确定规则,另一方面该规则确保了转发者与接收者的类型一致。
可以用两条语句来抽象表示转发者与接收者的参数类型叠加问题:
typedef T& TR;
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。
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
}
从上面代码种可以看到,当模板类型为左值时,其进入了目标函数的左值版本,当模板类型为右值时,其进入了目标函数的右值版本,转发函数可以视作不存在,这就是完美转发。