由浅入深地介绍右值引用、移动语义、引用折叠特性,解释何为完美转发,最后分析万能转发的具体实现过程
1、右值引用
右值:即出现在赋值运算符(等号)右边的项,只有临时的值而没有地址,因此无法使用取址运算符(*);右值包括字面常量(c风格的字符串除外,其表示地址)、表达式(如x+y)、函数的返回值(不能为引用类型的返回值,因为返回引用不会产生临时值)。
右值引用:可以引用右值的数据类型;用&&标识符标识。
int x = 3;
int y = 6;
int&& r1 = 13; //#1
int&& r2 = x + y; //#2
int&& r3 = std::sqrt(2.0); //#3
如上述r1、r2、r3。
其中#1~#3的过程实际上相当于创建了一个地址(变量名),然后将这个地址与右值关联起来,使之不再是临时值;因此可以用取指运算符查看他们的地址:
cout << &r1 << endl;
cout << &r2 << endl;
cout << &r3 << endl;
2、移动语义
上述的将右值赋值给右值引用的过程是触发了移动语义——即将数据所有权转让给引用,而非拷贝的过程。
因此在效率上移动语义的数据转让过程要比复制过程高效的多。
与复制相比运行效率的差距
vector<int> arrA, arrB;
for (int i = 0; i < 100000000; ++i) {
arrA.push_back(i);
}
for (int i = 0; i < 10; ++i) {
arrB.push_back(i);
}
std::copy(arrA.begin(), arrA.end(), std::back_inserter(arrB)); //copy element
例如上面的代码中,将A数组中的一亿个数据复制到B中(这里不用赋值运算符是因为新的stl实现中对赋值的重载也用了移动语义,可以查看vector的源码了解)
整个过程花费了15s多,复制过程占用超过了一半的运行时间,可以看到copy过程占用了51%的cpu时间。
接下来换成移动语义再测试一下:
vector<int> arrA, arrB;
for (int i = 0; i < 100000000; ++i) {
arrA.push_back(i);
}
for (int i = 0; i < 10; ++i) {
arrB.push_back(i);
}
arrB = std::move(arrA); //right value move
std::move(T& arg)函数的作用是用static_cast将arg强制转换为T&&类型,具体源码如下:
接着赋值过程就可以调用operator=(T&& arg)的重载,进行数据的转让;转让的过程相比复制效率极高:
可以看到整个运行时间为8490ms,为数组push_back的过程占用了96.86%cpu时间,因此数据转让的过程占用的cpu时间仅仅只有4%不到。
数据转让的具体过程
数据转让的具体过程可以类比为指针交换。例如如果我们想要交换两个数组,最具效率的做法应该是交换两个数组的指针:
vector<int>* P_arrC = P_arrA;
P_arrA = P_arrB;
P_arrB = P_arrC;
上述过程就是数据还留在原地,只进行了指针的交换;但这样做的前提是我们持有数据的地址(用指针创建、管理的数组)。
否则就只能用移动语义来实现;例如上述转移一亿个元素的数组的例子中,我们将运行的结果打印出来:
可以看到赋值前后,两个数组的地址都没有发生改变,而只进行了数据的转移,将数据绑定到了其他地址上。因此原数组的数据被转移后,它的元素个数就变成了0,因为它原有的数据的所有权被“夺走了”。
因此在没有持有数组指针的情况下,我们可以用移动语义来高效地进行两数组的交换:
vector<int>&& arrC = std::move(arrA);
arrA = std::move(arrB);
arrB = std::move(arrC);
实际上,上述代码就是std::swap函数的关键执行过程
Swap函数的更多用法*
上面说到swap函数实现就是利用了移动语义高效的进行数据所有权的转让、交换,关键部分源码如下:
因此可以利用swap函数高效的进行其他操作,例如去除数据的多余容量:
cout << &arrB << endl;
cout << arrB.size() << endl;
cout << arrB.capacity() << endl;
vector<int>(arrB).swap(arrB); //swap Function
cout << &arrB << endl;
cout << arrB.size() << endl;
cout << arrB.capacity() << endl;
可以看到经过创建新的数组并把数据转交给原数组,就可以使原数组的总容量=实际容量。
除此之外,还可以清空数组,具体过程与上面的一样,不同的是我们创建一个空数组与之交换即可:
...
vector<int>().swap(arrB); //swap Function
...
可以看到数组被成功清空了。
3、完美转发&万能引用
所谓完美转发,是指不仅是转发对象,还转发类型、左/右值、是否带有const/volation修饰;因此这个过程需要用到万能引用:
//头文件
#include <iostream>
#include <utility>
// 用于接受左值引用
void target(int& arg) {
std::cout << "左值: " << arg << std::endl;
}
// 用于接受右值引用
void target(int&& arg) {
std::cout << "右值: " << arg << std::endl;
}
//接受任意类型的模板函数,T&&可以进行万能转化(引用折叠,详见后文)
template <typename T>
void transFunction(T&& arg) {
// 使用 std::forward 将参数完美转发给其他函数(如何实现详见后文)
target(std::forward<T>(arg));
}
int main() {
int _left = 200;
int&& _right = _left + 3;
//完美转发
transFunction(_left); //#1
transFunction(_right); //#2
transFunction(std::move(_right)); //#3
transFunction(_left + 3); //#4
return 0;
}
运行结果如下:
关键在于transFunciton中,无论接受的是左值还是右值,需要利用std::forward来保留原始值类别从而进行完美转发,否则在转发过程中类型可能丢失;
如在#2一行,在首次传递过程中将_right视为左值,因此在到达target(std::forward<T>(arg)),参数类型已经丢失,因此调用了target(int& arg)的重载;如果需要保留参数类型,则一开始时就应该用forward转发:transFunction(std::forward<int>(_right))
std::move与std::forward的区别
std::move无条件的进行右值类型的强制转换;而std::forward仅对绑定到右值引用的右值类型进行强制转换
引用折叠
要具体分析两者如何实现的,需要查看具体的源码,在此之前介绍一下两者在实现过程中用到的一个特性:引用折叠——允许在模板元编程中使用引用类型的参数来创建新的引用类型,具体规则如下:
1)当一个右值引用参数被一个左值或左值引用初始化,那么引用将折叠为左值引用。(即T&& & –> T&)
2)当一个右值引用参数被一个右值引用初始化,那么引用将折叠为右值引用。(即T&& && 变成 T&&)
3)当一个左值引用参数被一个左值/左值引用或右值引用初始化,那么引用不能折叠,仍为左值引用(即:T& & –>T&,T& && –>T&)
4)左值引用参数无法被右值初始化
template<typename T>
void func_r(T&& arg);
template<typename T>
void func_l(T& arg);
int main() {
int a = 5;
int& ref_a = a;
int&& rref_a = a+1;
func_r(a); //1)int被作为int&,引用折叠为左值引用
func_r(ref_a); //1)arg为int&,引用折叠为左值引用
func_r(10+1); //arg为右值,不发生折叠,被初始化为右值引用
func_r(std::move(a)); //2)arg为int&&,引用折叠为右值引用
func_r(rref_a); //右值引用会被视为左值引用传递,因此发生1)情况的折叠,折叠为左值引用
func_l(a); //3)arg为int,引用不能折叠
func_l(ref_a); //3)arg为int&,引用不能折叠
func_l(std::move(rref_a)); //3)arg为int&&,引用不能折叠,亦被视为左值引用
//func_l(a+3); //4)非法,无法通过编译
}
因此总的来说,在模板编程中使用右值引用(T&&)作为参数时,才可以触发引用折叠行为,才能够保留原数据的类型,实现完美转发
标准库中的具体实现
现在再来看std::move和std::forward的具体实现:
*其中remove_reference_t<_Ty>为取结构体中成员的操作,目的是去除引用拿到_Ty的具体类型,即无论模板被初始化为int&还是int&&,这个操作都能得到基本的int类型,可以翻看源码来查找具体是如何实现的,此处不表
首先来看move函数,此函数的实现十分简单:使用remove_reference_t<_Ty>&&将右值引用作为返回值,接受可折叠的引用参数_Ty&&来接收右值或左值或两者的引用,并保留其类型;接着用static_cast将参数强制转换为右值引用类型返回。
然后来看forward函数,此函数有两个重载:
第一个重载利用remove_reference_t<_Ty>&接受不折叠的左值/左值引用,但在返回值时进行了折叠,因此forward<int>(arg)时将返回右值引用,forward<int&>(arg)时发生折叠从而返回左值引用,forward<int&&>(arg)时发生折叠从而返回右值引用
第二个重载利用remove_reference_t<_Ty>&接受不折叠的右值/右值引用,同样在返回值时进行了折叠。
注意:当调用forward<int&>(a+3)时,由于传递了右值因此进入了第二个重载,但由于模板参数_Ty为int&因此返回值会被折叠为左值引用,右值无法直接被强转为左值引用,因此会被第二个重载的第一行检测到从而触发“bad forward call”错误,表明这种调用是非法的。
应用在模板函数中
// 用于接受左值引用
void target(int& arg) {
std::cout << "左值: " << arg << std::endl;
}
// 用于接受右值引用
void target(int&& arg) {
std::cout << "右值: " << arg << std::endl;
}
template <typename T>
void transFunction(T&& arg) {
target(std::forward<T>(arg));
}
最后我们再回过头来看以上万能引用的实现过程:
transFunction以T&&作为参数,因此当用左值或左值引用初始化时(如上面代码示例中的#1行,左值实际上被作为左值引用传递了进去,与1)行相同),模板函数的参数被折叠为左值引用int&,调用forward时也将其返回值折叠为了int&,因此调用到了target的第一个重载
而当用右值或右值引用初始化时,模板函数的参数被折叠为int&&,forward返回值也被折叠为int&&,因此调用了target的第二个重载