Modern C++ | 移动语义与完美转发 | Universal Reference

简单总结一下C++ 11/14中的移动语义和完美转发~

lvalue && rvalue

表达式可以分为lvalue(左值)和rvalue(右值)两种。

左值与右值的区别是左值具名,可以取址 并访问;而右值不具名,通常是临时的变量,不可取址,仅在当前作用域有效,可以被移动。

对于函数及运算符,如果返回类型是左值引用类型(A&),那么返回值是左值;若返回类型是原对象类型(A),那么返回值就是右值。

举一些例子来说明:

1
2
3
4
5
6
7
8
int i = 2;
int a = 0, b = 1;
int *p = new int(24);
std::string str1 = "nice";
std::string str2 = "Scala";
std::vector<int> vec;
vec.push_back(24);
const int& m = 666;
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),那么表达式类型可以进一步细分为:

Value categories since C++ 11

根据上面的分类,我们传统意义上讲的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:
1
2
template<typename T>
void f(T&& param);

上面有两个要点:类型为T&&和参与类型推导。也就是说T&&加上cv qualifiers就不是universal reference了,比如下面的param参数为rvalue reference而不是universal reference:

1
2
template<typename T>
void f(const T&& param);

另外一点就是要参与类型推导。比如以下vector类中的push_back函数,虽然参数类型为T&&,但是并未参与类型推导。因为vector模板实例化的时候,T的类型是已经推导出来的(如std::vector<T>, T = int),因此参数仍然为rvalue reference:

1
2
3
4
5
6
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
// ...
};

emplace_back模板函数则需要进行类型推导,因此对应的参数Args&&为universal reference(为方便起见,将此处的parameter pack看作是一个type parameter):

1
2
3
4
5
6
7
template<class T, class Allocator = allocator<T>>
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);
// ...
};
  • 如果一个对象的类型被定义为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

考虑以下代码:

1
2
3
4
5
6
7
class A {};
template<typename T>
void f(T&& param);
A a;
f(a); // T = A&

根据上面总结的规则,T = A&, T&& = A&。然而将T&&展开后我们发现这玩意像是一个reference to reference:

1
void f(A& && param);

如果我们直接表示这样的多层引用,编译器会报错;而这里编译器却允许在一定的情况下进行隐含的多层引用推导,这就是 reference collapsing (引用折叠)。C++中有两种引用(左值引用和右值引用),因此引用折叠就有四种组合。引用折叠的规则:

如果两个引用中至少其中一个引用是左值引用,那么折叠结果就是左值引用;否则折叠结果就是右值引用。

直观表示:

  • T& & = T&
  • T&& & = T&
  • T& && = T&
  • T&& && = T&&

Move semantic

移动语义的意义:资源所有权的转让。即进行数据复制的时候,将动态申请的内存空间的所有权直接转让出去,不用再另开辟空间进行大量的数据拷贝,既节省空间又提高效率。

被移动的数据交出了所有权,为了不出现析构两次同一数据区,要将交出所有权的数据的指向动态申请内存去的指针赋值为nullptr,即空指针。对空指针执行delete[]是合法的。

我们可以用标准库中的std::move函数来表示一个对象可以被移动,即交出资源所有权。它可以将传入的参数转化为右值引用(严格来说应该是xvalue,即具名但可被移动的值)。比如:

1
2
3
4
5
6
7
8
f(int&& a);
f(const int& a);
// ...
int s = 4;
f(s); // invoke f(&)
f(std::move(s));// invoke f(&&)

再比如对于只允许移动的对象(独占资源,如unique_ptr),可以通过std::move与move ctor来转移资源的所有权:

1
2
auto p1 = std::make_unique<std::string>("+1s");
auto p2 = std::move(p1); // p1管理的资源的所有权交给p2

注意std::move的本质,它其实就是一个转换函数,将给定的类型转化为右值引用,而并不是真正地“移动”了资源(组委会一直在吐槽move这个名,应该叫rval比较合适)。我们来看一下其实现(gcc 5.3.0):

1
2
3
4
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

可以看到,move函数的实现非常简单:通过type_traits中的remove_reference函数清除_Tp类型中的引用,然后再加上&&并转换(此处_Tp&&即为一个universal reference)。

Perfect forwarding

有的时候,我们需要将一个函数某一组参数原封不动地传递给另一个函数。这里不仅需要参数的值不变,而且需要参数的类型属性(左值/右值,cv qualifiers)保持不变,这叫做 Perfect Forwarding(完美转发)。

从C++ 11开始,perfect forwarding可以通过std::forward函数实现,其原型为:

1
2
3
4
5
6
// C++ 14 definition
template< class T >
constexpr T&& forward( typename std::remove_reference<T>::type& t );
template< class T >
constexpr T&& forward( typename std::remove_reference<T>::type&& t );

写个例子,实现C++ 14标准中的make_unique函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <utility>
#include <memory>
class A {
public:
A(int && n) { std::cout << "rvalue constructor -> n=" << n << std::endl;}
A(int& n) { std::cout << "lvalue constructor -> n=" << n << std::endl;}
};
template<class T, class U>
std::unique_ptr<T> make_unique1(U&& u) {
return std::unique_ptr<T>(new T(std::forward<U>(u)));
}
int main() {
int i = 24;
auto p1 = make_unique1<A>(666); // rvalue forwarding
auto p2 = make_unique1<A>(i); // lvalue forwarding
auto p3 = make_unique1<A>(std::move(i)); // rvalue forwarding
return 0;
}

程序运行结果:

1
2
3
rvalue constructor -> n=666
lvalue constructor -> n=24
rvalue constructor -> n=24

可以看到,参数在传递时都保留了参数原本的属性。

看一下forward函数的实现(gcc 5.3.0):

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
}

可以看到forward函数底层也是利用了type_traits中的remove_reference以及universal reference和reference collapsing。


参考资料

文章目录
  1. 1. lvalue && rvalue
  2. 2. Universal reference
  3. 3. Reference collapsing
  4. 4. Move semantic
  5. 5. Perfect forwarding
  6. 6. 参考资料