C++11:右值引用
- 右值与左值
- 右值引用语法
- 右值引用底层
- 移动语义
- 引用折叠
- 完美转发
传统的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
的类型自动返回其原始的左右值属性了。