右值、右值引用、移动语义与完美转发

 

前言

好久没写c++了,因为最近做的一个工程需要解大规模的有特殊约束条件的线性方程组,库不好使,所以被逼无奈用了c++。又因为对效率要求较高,又想用oo的范式,所以还是免不了要用移动语义这种。而这几个东西我每次用到都要去查一下用法,下次又会忘记,所以我今天彻底理一理。

左值与右值

设有类A

1
2
3
4
5
6
7
8
9
10
class A {
public:
A() {
printf("build\n");
}
~A()
{
printf("destroy\n");
}
};

又有一段代码

1
A a = A{};

这里如果写成A()是有问题的,因为在新标准中这种行为被规范成了构造函数相当于A a()

此处a即为左值A()即为右值

通俗一点来说左值即在表达式结束后依然存在的值,右值即在表达式结束后自动析构的值。一般情况下,左值在赋值左边,反之反之。或者左值一般有姓名,反之反之。

左值引用与右值引用

左值引用

左值引用即对于左值的一种引用,是一种变量类型,效果类似于为左值取了一个别名。

1
2
A a;
A& b = a;

当你修改b时,a也会被修改。

当左值引用作为参数时,可以避免传参时的参数的拷贝构造与析构,有点类似于指针传参。

1
2
3
4
5
6
void lTest(A& a) {
// do something with a
}
void pTest(A* a) {
// do something with *a
}

右值引用

右值引用即对于右值的一种引用,也是一种变量类型,相当于为右值取一个别名,并将右值的生命周期改为右值引用的作用域,有点类似于将右值转化为左值。例如

1
A&& a = A();

在下面就可以把a当成普通的`类型值用了。

右值也可以作为参数传入

1
2
3
4
5
6
7
void rTest(A&& a) {
// do something with a
}

int main() {
rTest(A())
}

只有当形参为右值时才会触发,一般用于复用a里面的资源(a是右值,会被析构,之后都用不上了,里面的资源拷贝一份就浪费了,直接移动划算些)。

有些函数涉及到存储参数里面的资源,就需要区分实参是左值还是右值。

左值里面的资源因为被用到所以一般函数内拷贝一份。

右值在作为实参调用完函数后就会被析构,里面的资源也会一起消失。所以就不需要拷贝一份,直接从实参那里移动过来就行。

一般常用于类内有动态分配的资源这种情况。

例如vectorpush_back就有两个版本,功能一样,耗时差距很大。

1
2
3
4
5
6
7
void push_back(const _Ty& _Val) { // insert element at end, provide strong guarantee
emplace_back(_Val);
}

void push_back(_Ty&& _Val) { // insert by moving into element at end, provide strong guarantee
emplace_back(_STD move(_Val));
}

下面那个会直接移动走压入的资源,上面那个则是复制一份。当然因为这些函数里面多半涉及移动赋值或者移动构造,所以你的自定义类型应定义这些方法。

移动构造与移动赋值

如果你希望你的自定义类型可以在以右值的形式存在时被移动,那么你应该定义移动构造函数移动赋值函数

1
2
3
4
5
6
A(A&& other) {
// move the resource
}
void operator=(A&& other) {
// move the resource
}

注释中的~基本上等同于浅拷贝,并把被拷贝的指针置空。如果类成员支持移动,那么可以委托构造或直接赋值(需在外面套一个move()

这样当你的变量以以下形式出现在代码中时,会调用移动而非拷贝

1
2
A a(A{});
A a = A{};

移动语义

移动语义即主动触发移动的一种方法。c++里面的方法是std::move(有些ide会有::move),相当于告诉编辑器虽然我是左值但是请把我当成右值。

有些时候可以用右值版本的

例如

1
2
3
4
5
A a;
// do something with a
vector<A> as;
a.pushback(a);
// do other thing without a

这种情况下a在压入as后就不被用到,但是还是调用了左值版的pushback,里面的操作基于拷贝代价高些。

拷贝代价一般高于移动

可以将代码改为

1
2
3
4
5
A a;
// do something with a
vector<A> as;
a.pushback(move(a));
// do other thing without a

这样就好一点。

还有另外一种用于返回值的情况。

别急着喷我,看完。

例如

1
2
3
4
5
A f() {
A a;
// do something with a
return a;
}

这里面明明a在返回后就会被析构,不存在移动资源而导致不安全的问题,但是还是调用了低效的拷贝,这是不合理的。

改成

1
2
3
4
5
A f() {
A a;
// do something with a
return move(a);
}

就会把a当成右值进行处理,那就会调用移动构造函数。

这里没看明白的可以研究下自定义类型的传参与作为返回值的过程

当然,因为这种情况实在是太普遍了,所以在默认情况下编译器会自动进行返回值优化,把上面的代码改成下面的。

完美转发

大家应该有留意到右值引用这个类型其实是个左值所以有些时候在函数套函数这种转发的情况下,会丢失右值这一特性,变成左值。

例如

1
2
3
4
5
6
7
8
void f(int& i){}
void f(int&& i){}
void myForward(int& i){
f(i);
}
void myForward(int&& i){
f(i);
}

你预期的myForward是调用的右值版本的f,但是实际上i作为右值引用,它是左值,调用的是左值版本的f

一般的思路也就是

1
2
3
4
5
6
7
8
void f(int& i){}
void f(int&& i){}
void myForward(int& i){
f(i);
}
void myForward(int&& i){
move(f(i));
}

通用引用

这样确实可以解决问题,可是两个版本的myForward并没有本质性的区别,我们可以通过通用引用来实现将两者合一。

1
2
3
4
5
6
void f(int& i){}
void f(int&& i){}
template<typename T>
void myForward(T&& i){
f(i);
}

当成我们写出这样一个函数时,T&&就是一个通用引用,可以表示int&int&&

但是我们还没有解决问题,i是一个左值,被调用的一定是左值版的f。

通用引用与完美转发

这里我们需要将i还原为实参的性质,而move只能把i转换为右值,所以我们需要使用完美转发,在c++中的语法是forword<T>

1
2
3
4
5
6
void f(int& i){}
void f(int&& i){}
template<typename T>
void myForward(T&& i){
f(forword<T>(i));
}

这样就实现了通用版的myForword了。

后记

虽然我是明白理解概念的,但是想要文辞清楚的将它写出来,还是很困难。

而且在写完这边文章后,我发现想要搞清楚一个c++的技巧,你就需要搞清楚很多c++隐式的规则,而且组合在一起会形成difficult but simple这样奇怪的东西。c++越来越复杂,特性越来越多,很难说得上是明智,不过历史包袱重也是没办法的事。