简单总结一下C++ 11/14中的移动语义和完美转发~
lvalue && rvalue
表达式可以分为lvalue
(左值)和rvalue
(右值)两种。
左值与右值的区别是左值具名,可以取址 并访问;而右值不具名,通常是临时的变量,不可取址,仅在当前作用域有效,可以被移动。
对于函数及运算符,如果返回类型是左值引用类型(A&
),那么返回值是左值;若返回类型是原对象类型(A
),那么返回值就是右值。
举一些例子来说明:
|
|
Expression | Value category |
---|---|
4 | rvalue |
i | lvalue |
a+b | rvalue |
&a | rvalue |
*p | lvalue |
++i | lvalue(前缀自增运算符直接在原变量上自增) |
i++ | rvalue(后缀自增运算符先拷贝一份变量,自增后再重新赋值给原变量) |
std::string(“oye”) | rvalue |
str1+str2 | rvalue(重载的+ 运算符返回的是一个临时的std::string 对象而不是引用) |
vec[0] | lvalue(重载的[] 运算符返回类型为int& ) |
m | lvalue(引用了一个右值,但本身是左值) |
当然C++中表达式的分类还可以根据是否可移动及是否具名再细分。如果用m
表示可移动(movable),用i
表示具名(has identity),那么表达式类型可以进一步细分为:
根据上面的分类,我们传统意义上讲的lvalue指的是具名并且不可被移动的值,而rvalue指的是可被移动的值。
更详细的信息可参考Value categories。
Universal reference
C++ 11中引入了右值引用(rvalue reference)用于表示移动语义(绑定了右值)。在C++ 11中,类型T
的右值引用表示为T&&
。然而并不是所有的形如T&&
的类型都为rvalue reference。形如T&&
的类型有两种情况:一种是普通的rvalue reference,另一种则被称为 universal reference,它既可能表示lvalue reference,也可能表示rvalue reference。那么如何区分这两种引用呢?根据 Effective Modern C++, Item 24 总结一下:
- 如果一个函数模板参数的类型为
T&&
,其中T
是需要推导的类型,那么此处的T&&
指代universal reference。若不严格满足T&&
类型或没有发生类型推导,则T&&
指代rvalue reference。比如下面的param
参数即为universal reference:
|
|
上面有两个要点:类型为T&&
和参与类型推导。也就是说T&&
加上cv qualifiers就不是universal reference了,比如下面的param
参数为rvalue reference而不是universal reference:
|
|
另外一点就是要参与类型推导。比如以下vector
类中的push_back
函数,虽然参数类型为T&&
,但是并未参与类型推导。因为vector
模板实例化的时候,T
的类型是已经推导出来的(如std::vector<T>, T = int
),因此参数仍然为rvalue reference:
|
|
而emplace_back
模板函数则需要进行类型推导,因此对应的参数Args&&
为universal reference(为方便起见,将此处的parameter pack看作是一个type parameter):
|
|
- 如果一个对象的类型被定义为
auto&&
,则此对象为universal reference,比如auto&& f = g()
那么如何确定universal reference的引用类型呢?有如下规则:如果universal reference是通过rvalue初始化的,那么它就对应一个rvalue reference;如果universal reference是通过lvalue初始化的,那么它就对应一个lvalue reference。
确定了universal reference的引用类型后,编译器需要推导出T&&
中的T
的真实类型:若传入的参数是一个左值,则T
会被推导为左值引用;而如果传入的参数是一个右值,则T
会被推导为原生类型(非引用类型)。这里面会涉及到编译器的 reference collapsing 规则,下面来总结一下。
Reference collapsing
考虑以下代码:
|
|
根据上面总结的规则,T = A&, T&& = A&
。然而将T&&
展开后我们发现这玩意像是一个reference to reference:
|
|
如果我们直接表示这样的多层引用,编译器会报错;而这里编译器却允许在一定的情况下进行隐含的多层引用推导,这就是 reference collapsing (引用折叠)。C++中有两种引用(左值引用和右值引用),因此引用折叠就有四种组合。引用折叠的规则:
如果两个引用中至少其中一个引用是左值引用,那么折叠结果就是左值引用;否则折叠结果就是右值引用。
直观表示:
T& &
=T&
T&& &
=T&
T& &&
=T&
T&& &&
=T&&
Move semantic
移动语义的意义:资源所有权的转让。即进行数据复制的时候,将动态申请的内存空间的所有权直接转让出去,不用再另开辟空间进行大量的数据拷贝,既节省空间又提高效率。
被移动的数据交出了所有权,为了不出现析构两次同一数据区,要将交出所有权的数据的指向动态申请内存去的指针赋值为nullptr
,即空指针。对空指针执行delete[]
是合法的。
我们可以用标准库中的std::move
函数来表示一个对象可以被移动,即交出资源所有权。它可以将传入的参数转化为右值引用(严格来说应该是xvalue,即具名但可被移动的值)。比如:
|
|
再比如对于只允许移动的对象(独占资源,如unique_ptr
),可以通过std::move
与move ctor来转移资源的所有权:
|
|
注意std::move
的本质,它其实就是一个转换函数,将给定的类型转化为右值引用,而并不是真正地“移动”了资源(组委会一直在吐槽move
这个名,应该叫rval
比较合适)。我们来看一下其实现(gcc 5.3.0):
|
|
可以看到,move
函数的实现非常简单:通过type_traits
中的remove_reference
函数清除_Tp
类型中的引用,然后再加上&&
并转换(此处_Tp&&
即为一个universal reference)。
Perfect forwarding
有的时候,我们需要将一个函数某一组参数原封不动地传递给另一个函数。这里不仅需要参数的值不变,而且需要参数的类型属性(左值/右值,cv qualifiers)保持不变,这叫做 Perfect Forwarding(完美转发)。
从C++ 11开始,perfect forwarding可以通过std::forward
函数实现,其原型为:
|
|
写个例子,实现C++ 14标准中的make_unique
函数:
|
|
程序运行结果:
|
|
可以看到,参数在传递时都保留了参数原本的属性。
看一下forward
函数的实现(gcc 5.3.0):
|
|
可以看到forward
函数底层也是利用了type_traits
中的remove_reference
以及universal reference和reference collapsing。
参考资料
- Effective Modern C++
- Universal References in C++ 11 — Scott Meyers