c++右值引用、移动语义、完美转发、万能转发详解

由浅入深地介绍右值引用、移动语义、引用折叠特性,解释何为完美转发,最后分析万能转发的具体实现过程


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的第二个重载

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值