3.3 右值引用
我感觉,右值引用绝对是现代 C++ 中最重要的特性。解决了很多历史遗留问题。
右值引用消除了诸如 std::vector、std::string 等容器额外的开销,也使得函数对象容器 std::function 成为了可能。
左值、右值、纯右值、将亡值
我们先详细区分一些现代 C++ 中的各种值
左值
英文是 lvalue,表示的是存储在内存中、有明确存储地址(可寻址)的数据。
右值
英文是 rvalue,表示的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。
C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为纯右值、将亡值。
纯右值
英文是 prvalue,纯粹的右值。要么是纯粹的字面量,如 10, true等;要么是求职结果相当于字面量或临时对象,如 1 + 2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda 表达式都属于纯右值。
将亡值
英文是 xvalue,C++11 引入右值引用而提出的概念。表示即将被销毁、却能够被移动的值。
C++之父对于值的理解
B神原话:
For all the values, there were only two independent properties:
“has identity” – i.e. an address, a pointer, the user can determine whether two copies are identical, etc.
“can be moved from” – i.e. we are allowed to leave to source of a “copy” in some indeterminate, but valid state.
There are four possible composition:
iM: has identity and cannot be moved from (defined as lvalue)
im: has identity and can be moved from (defined as xvalue)
Im: does not have identity and can be moved from (defined as prvalue)
IM: doesn’t have identity and cannot be moved (他认为这种情况在 C++ 中是没有用的)
大概意思就是,C++ 中的值有两个属性,一个是身份,一个是可移动。
身份:能够确定某个表达式是否和另一个表达式指涉同一个实体,比如指针,名称之类的东西。
可移动:能够被移动构造函数、移动赋值操作符或者其它实现移动语义的重载函数绑定。
lvalue:所谓的左值,有身份,不能被移动
xvalue:所谓的将亡值,有身份,可移动
pvalue:所谓的纯右值,没有身份,可移动
另一种情况大神认为在 C++ 中没有意义。
如果想要学习更多的关于 C++ 值的知识,可以参考网站:cppreference.com
右值引用和左值引用
要拿到一个 xvalue,就需要用到右值引用:T &&,其中 T 是类型。右值引用的声明让这个临时值的生命周期得以延长、只要变量活着,那么 xvalue 将继续存活。
C++11 提供了 std::move 这个方法讲左值参数无条件的转换为右值。如下代码:
#include <iostream>
#include <functional>
#include <string>
using namespace std;
void reference(string &str)
{
cout << "lvalue" << endl;
}
void reference(string &&str)
{
cout << "rvalue" << endl;
}
int main()
{
string lv1 = "string,"; //lv1 是一个 lvalue
//string&& r1 = lv1; //非法,rvalue 引用不能引用 lvalue
string &&rv1 = move(lv1); //合法,std::move 可以将 lvalue 转移为 rvalue
cout << rv1 << endl; //string
const string &lv2 = lv1 + lv1; //合法,常量 lvalue 引用能够延长临时变量的生命周期
//lv2 += "Test"; //非法,常量引用无法被修改
cout << lv2 << endl; //string,string
string &&rv2 = lv1 + lv2; //合法,rvalue 引用延长临时对象生命周期
rv2 += "Test"; //合法,非常量引用能够修改临时变量
cout << rv2 << endl; //string,string,string
reference(rv2); //输出 lvalue
return 0;
}
注意,rv2 虽然引用了一个右值,但是由于它是一个引用,所以 rv2 依然是一个左值。
这里有一个比较有意思的事,先看下面代码:
#include <iostream>
using namespace std;
int main()
{
//int &a = move(1); //不合法,非常量 lvalue 引用只能绑定到 lvalue
//这里说一下,move(1) 和 1 是一样的,就是说 move() 对 rvalue 操作没有效果
const int &b = 1; //合法,常量 lvalue 引用允许绑定 rvalue
cout << b << endl;
return 0;
}
书上写的没理解上去,下面说一下个人看法。
我的理解,引用是一段内存地址的别名。
第一个问题,非常量 lvalue 引用不能绑定 rvalue。我的看法是,从内存的角度,1 和 move(1) 都是在常量区的(这里关于 C++ 的分区一直各有各的说法),总之就是一段不可能更改的内存。对这段内存的所有更改操作都应该不合法。
按照我的理解,第二个问题:常量 lvalue 引用允许绑定 rvalue 就好解释了。常量引用本身就不可以修改,绑定到一个 rvalue 没有问题。
还有,书上的这段代码是跑不过的,代码如下:
void increase(int &v)
{
v++;
}
void foo()
{
double s = 1;
int t = 1;
increase(s); //不合法
}
按照我的理解,不是像书上说的那样会诞生一个所谓的临时值保存 s 的值,而是直接报错。原因就是 int 的内存规则和 double 的内存规则不一样。没有办法用 int 内存的别名去操作 double 的内存。
关于这块的东西,完全是个人理解,和书上的角度完全不一样。如果有人有更好的理解,欢迎评论区里讨论。我也能白嫖一下。
移动语义
传统 C++ 中,为了实现资源的移动操作,必须先复制、再析构,否则就要自己实现移动的接口。因为这一点,C++ 被诟病了许多年。
传统 C++ 没有区分“移动”和“拷贝”的概念,造成了大量的数据拷贝,浪费时间和空间。右值引用完美的解决了这个问题。如下代码:
#include <iostream>
using namespace std;
class A
{
public:
A() : pointer(new int(1))
{
cout << "构造" << pointer << endl;
}
A(A &a) : pointer(new int(*a.pointer))
{
cout << "拷贝" << pointer << endl;
} //无意义的对象拷贝
A(A &&a) : pointer(a.pointer)
{
a.pointer = nullptr;
cout << "移动" << pointer << endl;
}
~A()
{
cout << "析构" << pointer << endl;
delete pointer;
}
int *pointer;
};
//防止编译器优化,保证一定是一个 xvalue
A return_rvalue(bool test)
{
A a, b;
if (test)
return a;
else
return b;
}
int main()
{
A obj = return_rvalue(false); //这里 obj 通过移动构造而来,不需要拷贝一份数据
cout << "obj:" << endl;
cout << obj.pointer << endl;
cout << *obj.pointer << endl; //因为是移动而来,所有数据的地址和原来是一样的
}
构造006CD240 //a 的构造
构造006C6E30 //b 的构造
移动006C6E30 //通过移动构造生成 obj
析构00000000 //b 被析构,注意,这时 b 的数据已经被移动到 obj
析构006CD240 //a 被析构
obj:
006C6E30 //obj 的数据,从 b 移动而来,所以和原来一样
1
析构006C6E30 //obj 的析构
说一下上面代码的思路:
1.首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出。return_rvalue 函数保证了一定会得到一个 xvalue。传入的 bool 没有实际意义,只是为了展示代码。
2.函数返回后,产生一个 xvalue,被 A 的移动构造引用,从而延长了生命周期,并将这个 xvalue 中的指针拿到,保存到了 obj 中,而 xvalue 的指针被设置为 nullptr,防止这块内存被销毁。
上面代码,相比于传统 C++,避免了毫无意义的拷贝构造,提升了性能。再来看看涉及标准库的例子,代码如下:
#include <iostream>
#include <utility>
#include <vector>
#include <string>
using namespace std;
int main()
{
string str = "Hello world.";
vector<string> v;
v.push_back(str); //会产生拷贝行为
cout << "str:" << str << endl; //输出 str:Hello world.
v.push_back(move(str)); //会产生移动行为,str 的值会变空
cout << "str:" << str << endl; //输出 str:,因为
return 0;
}
完美转发
前面我们说了,一个声明的右值引用,实际上还是一个左值。这就为我们进行参数传递造成了问题。如下代码:
#include <iostream>
#include <utility>
using namespace std;
void reference(int &v)
{
cout << "lvalue" << endl;
}
void reference(int &&v)
{
cout << "rvalue" << endl;
}
template <typename T>
void pass(T &&v)
{
cout << "普通传参:";
reference(v);
}
int main()
{
cout << "传递 rvalue" << endl;
pass(1); //1 是右值,但是输出是左值
cout << "传递 lvalue" << endl;
int s = 1;
pass(s); //l 是左值,输出是右值
return 0;
}
对于 pass(1) 来说,虽然传递是一个 rvalue,但是由于 v 是一个引用,所以同时也是左值。因此 reference(v) 会调用 reference(int &)。而对于 pass(s) 而言,s 是一个左值,为什么也会成功传给 pass(T &&) 呢?
这是因为”引用坍缩规则“(书上这么叫的,但是也有的地方叫做引用折叠),反正不管他名字叫什么东西了(国人很愿意高一些奇奇怪怪的名称),规则是一样的。
传统 C++ 中,不能对一个引用继续引用。但是,rvalue 引用的出现,让我们可以进行一种所谓的”引用的引用“。但是需要遵循以下规则:
函数形参类型 | 实参参数类型 | 推到后函数形参类型 |
---|---|---|
T& | lvalue 引用 | T& |
T& | rvalue 引用 | T& |
T&& | lvalue 引用 | T& |
T&& | rvalue 引用 | T&& |
也就是说,只有当形参和实参都是 rvalue 引用时,才会推导为 rvalue 引用类型。
由于 rvalue 引用的定义实际上也是一个 lvalue,我们不能直接传递。所以出现了完美转发。
完美转发,就是让我们在传递参数的时候,保持原来的参数类型(lvalue 引用保持 lvalue 引用,rvalue 引用保持 rvalue 引用)。我们可以使用 std::forward 来进行参数的传递。代码如下:
#include <iostream>
#include <utility>
using namespace std;
void reference(int &v)
{
cout << "lvalue 引用" << endl;
}
void reference(int &&v)
{
cout << "rvalue 引用" << endl;
}
template <typename T>
void pass(T &&v)
{
cout << " 普通传参:";
reference(v);
cout << " std::move 传参:";
reference(move(v));
cout << " std::forward 传参:";
reference(forward<T>(v));
cout << "static_cast<T&&> 传参:";
reference(static_cast<T &&>(v));
}
int main()
{
cout << "传递 rvalue:" << endl;
pass(1);
cout << "********************" << endl;
cout << "传递 lvalue:" << endl;
int v = 1;
pass(v);
return 0;
}
无论传递参数是 lvalue 还是 rvalue,普通传参都会都会将参数作为 lvalue
转发。就是说,pass 函数体中所有的 v 变量全是 lvalue。所以 std::move 一定是接受了一个 lvalue,一定是调用了 reference(int &&) 输出 rvalue 引用。
唯独 std::forward 既没有多余的拷贝,同时完美转发了函数的实参。也就是说 lvalue 还是 lvalue,rvalue 还是 rvalue。
从结果上来看,std::forward<T>(v) 和 static_cast<T&&>(v) 是完全一样的。
书上还给我们展示了一波源码,代码如下:
template <typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type &__t) noexcept
{
return static_cast<_Tp &&>(__t);
}
template <typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type &__t) noexcept
{
static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" "substituting _Tp is an lvalue reference type");
return static_cast<_Tp &&>(__t);
}
以下内容为书中原话:
在这份实现中,std::remove_reference 的功能是消除类型中的引用,而 std::is_lvalue_reference 用于检查类型推导是否正确,在 std::forward 的第二个实现中检查了接收到的值确实是一个 lvalue,进而体现了坍缩规则。
当 std::forward 接受 lvalue 时,_Tp 被推导为左值,所以返回值为 lvalue;而当其接受 rvalue 为右值时,_Tp 被推导为 rvalue 引用,则基于坍缩规则,返回值便成为了 && + && 的 rvalue。可见 std::forward 的原理在于巧妙的利用了模板类型推导过程中产生的差异。
这时我们能回答这样的一个问题:为什么在使用循环语句的过程中,auto&& 时最安全的方式?因为当 auto 被推导为不同的 lvalue 和 rvalue 时,与 && 的坍缩组合是完美转发。
总结
这块的东西写的比较多,也比较深。因为这里涉及到性能上的提升,个人感觉只要是在 C++ 中涉及到性能上的提升,就应该深入一点。
作为一门系统及编程语言,性能应该是首选。这也是 C++ 比其他的编程语言的强大之处。但是,这也为初学者产生了更多的学习代价,所以说 C++ 是一门可以学习一生的语言。