传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用
。无论左值引用
还是右值引用
,都是给对象取别名。
右值与左值
在讲解右值引用之前,我们就需要先辨析一下左值
与右值
的区别。
左值
左值是一个表示数据的表达式,我们可以获取它的地址并且对其赋值,左值可以出现在赋值操作符=
的左边,但是右值不能。
比如以下代码中:
int i = 0; int* p = &i; double d = 3.14;
变量i
,p
,d
都是左值,一方面来说,它们出现在了=
的左边,另一方面来说,我们可以对其取地址,并修改它的值。
当然,我们也有const
变量:
const int ci = 0; int const* cp = &i; const double cd = 3.14;
变量ci
,cp
,cd
都是左值,它们出现在了=
的左边,我们可以对其取地址。但是由于具有const
属性,我们不能修改它。
因此, 左值
最显著的特征是可以取地址,但是不一定可以被修改。
右值
右值也是一个表达数据的表达式,比如字面常量
,表达式返回值
,函数返回值
等等,右值可以出现在赋值操作符=
的右边,但是不能出现在=
的左边,右值不能取地址。
比如以下代码中:
double func() { return 3.14; } int x = 10; int y = 20; int z = x + y; double d = func();
以上代码中,10,20,x + y,func()都是右值,它们出现在=的右边。10,20对应了字面常量;x + y对应了表达式返回值;func()对应函数返回值。这些都是右值,它们最显著的特点就是无法取地址。
简单辨析了什么是左值,什么是右值,现在我们知道左值与右值的最大区别在于可不可以取地址,接下来我们就要讲解右值引用这个语法了。
右值引用语法
先回顾一下左值引用的语法:
int i = 0; int* p = nullptr; int& ri = i; int*& rp = p;
左值引用只需要在原本变量的类型后面,加一个&
,就是一个左值引用的类型。左值引用后,新的变量相当于原先变量的别名,我们可以传引用传参,传引用返回等操作,来减少拷贝。
但是我们不能左值引用一个右值,比如这样:
int& ri = 0; int*& rp = nullptr; double& rd = 3.14;
以上代码中,=
右侧都是字面常量,也就都是右值,而我们变量ri
,rp
,pd
都是左值引用。我们不能拿左值引用来引用右值。
左值引用的语法是:type&
;右值引用的语法是:type&&
。
接下来我们尝试对刚刚的值进行右值引用:
int&& ri = 0; int*&& rp = nullptr; double&& rd = 3.14;
这样我们就完成了对右值的引用。
现在我们有了右值引用语法,那么再来考虑两个问题:
- 左值引用可以引用右值吗?
- 右值引用可以引用左值吗?
也许你会感到疑问,我刚刚已经证明过了无法直接通过左值引用引用右值,为什么我还要提出这个问题。这是因为刚刚的测试不全面,没有考虑特殊情况。
- 左值引用不能直接引用右值
- const左值引用 可以引用右值
在刚刚的测试用例中,我们尝试用左值引用直接引用右值:
int& i = 5; // 非法
这是不允许的,不然就没必要再推出右值引用语法了。
但是如果我们以const
引用的形式,那么就可以引用右值:
const int& i = 5; // 合法
一个常量具有常性,也就是不能修改,如果我们直接把一个常量交给引用,那么我们就可能通过引用来修改这个常量,这就违背了常性。因此不能直接引用一个右值常量,但是当我们使用const
引用,那么就可以引用了。
- 右值引用不能直接引用左值
- 右值引用可以引用
move
后的左值
右值引用不能直接引用左值:
int i = 5; int&& rri = i; // 非法
但是C++11后,提供了一个函数move
,其可以把一个左值强制转化为一个右值。
就像这样:
int i = 5; int&& rri = move(i); // 合法
要注意的是: move
并不会改变参数本身的左值属性,这一点可以参考强制类型转化:
double d = 3.14; int i = (int)d;
在以上代码中,(int)d
这个强制转化过程,并没有改变d
是一个double
类型的数据,只是在这个表达式中,(int)d
返回了一个int
类型的d
。
同理move(i)
之后,i
依然是左值,但是move(i)
这个表达式返回了一个右值的i
。
右值引用底层
既然存在右值引用这个语法,那么我们来看看右值引用到底干了些啥。
右值引用的工作主要有两种情况,一种是右值引用了常量,另外一种是右值引用了move
后的左值。
右值引用了常量:
当右值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据
看到一段代码:
int&& r = 5; r = 10;
以上过程中,我们先用r右值引用了常量5,然后通过右值引用把5改为了10。
如果这个过程中,右值常量5存储在常量区,r右值引用后如果r指向常量区的5,会发生什么?此时我们的r = 10操作,就相当于把常量区的5修改为了10,从此以后整个程序中只要去常量区拷贝5都会变成拷贝10,这可就完蛋了。因此我们的右值引用常量,绝对不能直接引用常量区的数据!!
因此,右值引用常量时的真实操作是把常量区的数据拷贝到栈区中,然后这个引用指向这一块栈区内存。
别忘了,我们的const
左值引用也可以引用常量,那么这个引用又是如何进行的:
const int& r = 5;
你可能会想,反正const
左值引用都不会修改数据,就算让r
真的指向常量区的5
也没啥问题。但是其实const
左值引用常量时和右值引用是一样的,都是先把数据拷贝到栈区,再进行引用。
总结如下:
- 当右值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据,该数据可以修改
- 当
const
左值引用了常量,引用会把常量区中的数据拷贝一份到栈区,然后该引用指向栈区中拷贝后的数据,但是该数据是常量,不能修改
右值引用了move后的左值:
- 当右值引用了
move
后的左值,右值引用直接指向该左值
看到以下代码:
int i = 5; int&& rri = move(i); rri = 10; cout << i << endl; cout << rri << endl;
程序输出结果为:
10 10
也就是说,我们可以通过修改右值引用来修改左值,或者说以通俗点的说法,此时右值引用就是这个左值的别名。
这一幕好像似曾相识,是不是左值引用也可以做到这个事情,甚至是一模一样的事情?
确实是这样的,当右值引用了move
后的左值,其实和直接左值引用这个左值没有任何区别。那么为什么我们还需要右值引用?
为了搞清右值引用存在的意义,我们先来看看左值引用出现后,解决了那些问题,又没解决哪些问题:
- 左值引用解决了传参时存在的拷贝问题
string add_string(string& s1, string& s2) { string s = s1 + s2; return s; } int main() { string str; string hello = "Hello"; string world = "world"; str = add_string(hello, world); return 0; }
以上代码中,add_string
函数需要接收两个string
类型的参数,此时我们使用传引用传参,就可以避免两个string
的拷贝消耗。
- 左值引用解决了一部分返回值的拷贝问题
string& say_hello() { static string s = "hello world"; return s; } int main() { string str1; string str2; str1 = say_hello(); return 0; }
以上代码中,函数say_hello生成了一个string,并把它返回给外部,如果我们直接返回,那么str1接收参数时,就会先拷贝构造出一个临时变量,然后临时变量再拷贝构造str1。这个过程发生了两次拷贝构造。但是返回值s指向的string是全局的,其出了函数依然存在,因此我们传引用返回,可以不用拷贝构造一个临时变量,直接拿返回值s去拷贝构造,节省了一次拷贝构造。
也就是说,左值引用通过传引用传参
和传引用返回
节省了拷贝。
但是我们再看到以下情况:
string say_hello() { string s = "hello world"; return s; } int main() { string str; str = say_hello(); return 0; }
以上代码中,say_hello依然返回hello world这个字符串,但是s是一个局部变量,因为出了函数就会被销毁,如果str想要接收到s,那么就会先拷贝构造一个临时变量,然后临时变量再拷贝构造出str。
但我们已经通过s创建好了一个字符串,我们为了得到一个字符串hello world,中间经过了这么多次拷贝。就因为这是一个局部变量,s不能出作用域。我们有没有办法直接把局部变量创建好的hello world移交给作用域外部的str,免去临时变量的拷贝构造?
因此,右值引用应运而生。
我们先前说过,右值引用当引用一个被move左值的时候,其本质和左值引用没有区别。右值引用,其实更多的是一种标记。
我们先来看看什么情况下会产生可以被右值引用的左值:
- 当一个左值被move后,可以被右值引用
- C++会把即将离开作用域的非引用类型的返回值当成右值,这种类型的右值也称为
将亡值
回顾我们刚刚的情况:函数内部的局部变量s
已经创建好了字符串hello world
,但是s
马上就要出函数作用域销毁了,于是把hello world拷贝一份给外部临时变量,s被销毁后,临时变量再拿拷贝到的hello world去拷贝构造str。
这个过程中,变量s已经快要离开作用域了,马上就要被销毁,s被销毁没有问题,但是s内部的hello world是我们需要的。这种情况可以理解为:一个富翁快要死亡了,于是他在死前立遗嘱,把自己的金钱继承给谁。
同理,一旦左值得到了右值属性,相当于立好了遗嘱,不希望自己的资源被系统释放,而是被合适的对象继承走。
由于C++会把即将离开作用域的非引用类型的返回值当成右值,这种类型的右值也称为将亡值 。s即将被销毁,此时s就是一个右值了,右值的意思就是:这个变量的资源可以被迁移走。这句话非常非常重要!!!
右值的意思就是:这个变量的资源可以被迁移走
我们再看到另外一种情况:
- 当一个左值被move后,可以被右值引用
C++之所以要给出一个move
属性,是因为有一些变量,其生命周期还很长,C++不敢擅自把这个变量的资源迁移走。但是一旦程序员把这个变量move
了,就得到了一个有右值属性的左值,此时相当于程序员亲自许可把这个变量的资源迁移走。
那么右值是如何把资源迁移走的呢?这就涉及到右值引用的移动语义
了:
移动语义
为了讲解移动语义,我先写一个简单的mystring
类:
class mystring { public: //构造函数 mystring(const char* str = "") { _str = new char[strlen(str) + 1]; strcpy(_str, str); } //析构函数 ~mystring() { delete[] _str; } // 赋值重载 mystring& operator=(const mystring& s) { cout << "赋值重载" << endl; return *this; } // 拷贝构造 mystring(const mystring& s) { cout << "拷贝构造" << endl; } private: char* _str = nullptr; };
这个mystring
类中,我没有具体实现每一个接口,因为移动语义中,更重要的是函数的调用关系,而不是函数的具体实现。在mystring
类中,有一个成员_str
,类型为char*
指针,指向一块空间,内部存储了字符串的字符。
现在我们有如下过程:
mystring get_string() { mystring str("hello"); return str; } int main() { mystring s2 = get_string(); return 0; }
s2
通过函数get_string
来获得字符串,并构造自己。这个过程中,由于str
是局部变量,会发生拷贝构造临时变量,临时变量再拷贝构造s2
的过程。但是由于str
是一个将亡值
,具有右值属性,我们可以写一个函数直接把它的资源转移走:
class mystring { public: // 移动构造 mystring(mystring&& s) { cout << "移动构造" << endl; std::swap(_str, s._str); } };
这个移动构造函数的参数是一个mystring&&类型,也就是一个右值引用。函数主体部分,通过一个swap函数把参数s的_str指针成员与自己的_str成员进行交换。由于指针指向字符串数组,此时相当于把s的字符串数组交换给自己,这样就完成了对右值引用的数据转移。
除了移动构造,我们还有原先的拷贝构造:
class mystring { public: // 移动构造 mystring(mystring&& s) { cout << "移动构造" << endl; std::swap(_str, s._str); } // 拷贝构造 mystring(const mystring& s) { cout << "拷贝构造" << endl; } };
那么为什么get_string要去调用移动构造而不是调用拷贝构造呢?
因为get_string的返回值是一个mystring类str,但是由于str要出作用域了,被判断为将亡值,因此str具有右值属性。那么str在出生命周期,构造临时变量的时候,就会去调用临时变量构造函数,由于str是构造函数的参数,具有右值属性,而不是左值属性,因此调用的是mystring&&的移动构造,而不是调用const mystring&的拷贝构造。
而我们的临时变量的生命周期,只在get_string这一行,马上就要被销毁了,因此临时变量也是一个将亡值,具有右值属性。当拿临时变量构造s2的时候,又会调用一次移动构造。流程如下:
get_string返回值str = =移动构造 = => 临时变量
临时变量 = =移动构造= => s2
可以看到,原先是进行两次拷贝构造,如果我们字符串有一亿个字符,那么总共要拷贝两亿个字符。
但是移动构造出现后,我们只需要进行两次移动构造,一次移动构造只交换一个指针,共交换两个指针。
现在可以看出,右值引用带来的移动构造有多么强悍。
虽然说我们的左值引用,也可以达到这样的移动构造,但是有一个问题,并不是所有的对象,资源都是可以被转移走的。移动构造之所以这么叫,就是因为移走了别人的资源。这部分资源之所以会被移走,就是因为它有右值属性。而它之所以有右值属性,要么就是这个变量是个将亡值,资源不转移就浪费了;要么就是被程序员亲自move了,程序员许可把这个对象的资源转移走。
就是这样的一个逻辑闭环,右值引用以一个既安全,又高效的方式,完成了局部变量的资源拷贝问题。而这个过程,也叫做右值引用的移动语义。
移动:改语法实现了通过移走别人的资源,实现高效的创建对象,避免大量拷贝
语义:在这个过程中,右值引用只提供语义层面的功能,即许可一个对象资源被转移的右值语义
因为右值引用的出现,C++11后,类的默认成员函数从6个变成了8个。新增两个成员函数:移动构造,移动赋值重载。
比如我刚刚的mystring
类的移动构造
和移动赋值
:
//移动赋值重载 mystring& operator=(mystring&& s) { std::swap(_str, s._str); return *this; } // 移动构造 mystring(mystring&& s) { std::swap(_str, s._str); }
它们的特点是:参数为右值引用,函数体内部通过交换别人的指针到自己手上,实现高效的资源转移。
当然,STL
库内部的所有容器,也都更新了移动构造
和移动赋值重载
。
这是C++11的vector
构造函数:
这里多出来了一个move
系列的构造函数,参数类型为vector&&
右值引用,这就是vector
的移动构造。
这是C++11的vector
的operator=
:
一样的,多出来一个系列的operator=
,参数类型为vector&&
右值引用,这是vector
移动赋值重载。
引用折叠
看到以下代码:
template <class T> void func(T&& t) { cout << "T&& 右值引用" << endl; } template <class T> void func(const T& t) { cout << "const T& const左值引用" << endl; } int main() { int a = 5; func(a);//左值 func(move(a));//右值 return 0; }
以上代码中,有两个模板函数的特化,分别是func
的右值引用特化T&&
和const左值引用特化const T&&
。请问:
向函数func
传入一个左值a
,会调用哪一个函数;像函数func
传入一个右值move(a)
,会调用哪一个函数?
程序输出结果如下:
T&& 右值引用 T&& 右值引用
可以看到,不论是左值还是右值,都调用了这个右值的模板,这是为什么?/按理来说,虽然const T&与int&类型不符,但是从一个一般的引用int&转为const int&是完全合理的,所以应该调用const T&版本才对。但是最后调用了T&&版本,是不是说明在模板中,T&左值引用可以转化为T&&右值引用?
这听起来太扯了,其根本原因在于,C++希望通过统一的方式来处理引用的模板:
因此C++在模板中推出了引用折叠,也叫做万能引用,规则如下:
T& && 推演为 T&
T&& && 推演为 T&&
如果你希望当参数为左值引用和右值引用的时候,函数的功能是一样的,你就可以只写一个函数:
template <class T> void func(T&& t) { }
此时,参数T&&
就已经是一个引用折叠了。现在我们来调用这个函数:
int a = 5; func(a); func(move(a));
我们共调用了两次函数,分别是左值引用传参和右值引用传参。
第一次传参,func(a);,模板参数T的类型为int&,但是参数类型为int& &&,此时根据折叠引用规则:int& &&等于int&
第二次传参,func(move(a));,模板参数T的类型为int&&,但是参数类型为int&& &&,此时根据折叠引用规则:int&& &&等于int&&
可见,其实这就是一个统一处理左值引用和右值引用的语法,你传入的参数是什么引用,最后T&&就是什么引用。当然,这套规则也对const引用生效。
因此我们刚才的模板,如果作用于int
类型,就可以推演出四套函数重载:
void func(int&){}; void func(const int&){}; void func(int&&){}; void func(const int&){};
我们可以用一套模板,生成原先两套模板才能做的事情(前提是左值引用右值引用对函数的要求相同)。
完美转发
看到以下代码:
void fuc1(int& rri) { cout << "func1 左值引用" << endl; } void fuc1(int&& rri) { cout << "func1 右值引用" << endl; } int main() { int i = 5; int&& rri = move(i); fuc1(rri); return 0; }
请问输出结果是什么?
输出结果:
func1 左值引用
是不是有点出乎意料?
明明我们的rri是一个右值引用,却调用了左值引用的函数重载,这又是为啥?
这涉及到一个重要知识点:
右值引用后,右值引用指向的对象是右值属性,但是引用本身是左值属性
比如说:int&& r = 5;这个代码,5的属性是右值,但是r的属性是左值。
因此我们在调用函数fuc1(rri);的时候,rri是一个左值,自然就以左值的形式来调用函数了。这该怎么办?
聪明的人就会想到,调用之前move
一下不就好了,比如这样:
fuc1(move(rri));
这样确实没有问题,可以解决我们刚才的困境。那么我们再来看到一个案例:
void func2(int& x) { cout << "func2 左值引用" << endl; } void func2(int&& x) { cout << "func2 右值引用" << endl; } template <class T> void fuc1(T&& t) { func2(t); } int main() { int i = 5; fuc1(i);//左值 fuc1(move(i));//右值 return 0; }
func1是一个引用折叠的函数模板,随后在func1中调用了func2,请问如何调用funx2参数的最开始的引用类型?
由于在func1中,我们经过了折叠引用这一步,T&&这个参数类型是不确定的。
如果T&&是右值的话,传参后t会变成左值,那么我们可以对其进行move操作
如果T&&是左值的话,传参后t还是左值,我们无需对其进行操作
这个地方就不能粗暴的进行move了,不然会把原本就是左值的参数,给move成右值。为了解决这个情况,C++提供了一个函数模板forward,称为完美转发,其可以识别到参数的左右值类型,从而将其转化为原来的值。我们只需要在引用折叠中这样进行调用:
template <class T> void fuc1(T&& t) { func2(forward<T>(t)); }
在forward
的模板参数中传入引用折叠的模板参数T
,那么forward<T>
就可以根据t
的类型自动返回其原始的左右值属性了。