简单介绍

C++引入右值引用,是为了支持“移动语义”和“完美转发”。不过在讨论它们之前,让我们先简单了解一下右值引用。

首先,什么是左值,什么是右值?
简单来说,左值是在内存中有具体表示,而且可以通过一个代表内存地址的变量名来引用的值;其他的都是右值。

  1. // lvalues:
  2. //
  3. int i = 42;
  4. i = 43; // ok, i is an lvalue
  5. int* p = &i; // ok, i is an lvalue
  6. // rvalues:
  7. //
  8. int foobar();
  9. int* p2 = &foobar(); // error, cannot take the address of an rvalue
  10. int j = foobar(); // ok, foobar() is an rvalue
  11. int* p3 = &j; // ok, j is lvalue
  12. j = 42; // ok, 42 is an rvalue

使用连续的两个&符号: && 表示右值引用,例如:void foo(std::string&& str)。这个函数只接受右值:

  1. foo(std::string("hello, world!")); // ok
  2. std::string functionThatReturnString() {
  3. return std::string("hello");
  4. }
  5. foo(functionThatReturnString()); // ok
  6. std::string str;
  7. foo(str); // error

如果还没开始头疼的话,下面这个很可能让你开始头疼:
右值引用不是右值。一旦一个右值引用绑定了一个右值,右值的这片临时内存就可以通过这个右值引用访问了,因此一个绑定了右值的右值引用是一个左值。

  1. void bar(std::string &str);
  2. void foo(std::string&& str) {
  3. bar(str); // ok
  4. bar(std::string("hello")); // error
  5. }
  6. foo(std::string("helloworld"));

记住了上面的概念后,让我们用右值引用来解决移动语义和完美转发问题,来消化消化这些概念。

移动语义

右值具备一个相当有用的特性:它们是在当前语句之后,就会被销毁的值,它们会临时占据一片内存,但由于这片内存在当前语句之后就不可访问了,因此编译器可以立即安全的销毁这片内存。
因为右值的这种特性,我们可以安全的将右值对象的内容“移动”走,将右值对象内容“移动”到我们的左值对象中。

在定义类时,我们需要明确我们的类是否支持move constructor以及move assignment operator,它们的定义方式如下(实现可以参考RAII那篇文章):

  1. class Foo {
  2. public:
  3. // rhs引用的是一个在当前函数调用结束时,就会被销毁的对象,因此我们可将rhs的内容“移动”
  4. this中,注意这里要做的是“移动”,而不是“赋值”,移动之后,右值对象被移动的属性必须要被清空
  5. Foo(Foo&& rhs) {...}
  6. // 同上,rhs即将被销毁,但这里做的是赋值,相比上面的构造,赋值后我们要确保this原来管理的
  7. // 资源被销毁,这可以通过“交换”this和rhs资源来实现,利用rhs即将被销毁的特点,将希望被销
  8. // 毁的this管理的资源和rhs管理的资源互换,这样rhs销毁时,就会销毁原来由this管理的资源。
  9. Foo& operator=(Foo&& rhs) {...}
  10. }
  11. // now we can do:
  12. Foo createFoo();
  13. Foo(createFoo()); // move constructor
  14. Foo foo;
  15. foo = createFoo(); // move assignment

右值引用让下面两种“移动”操作成为可能:

  1. 如果一个变量绑定的值马上就要被销毁了,而我们又需要这个变量绑定的值,我们就可以将值的某些内容安全地“移动”到另一个变量值里。

    1. std::string hello = "hello";
    2. std::string world = "world";
    3. // 没有右值构造函数,string只能拷贝一份 hello + world 产生的临时string对象的数据
    4. std::string helloWorld = hello + world;
  2. 让我们能够使用C++定义可移动但不可拷贝的类,例如:unique_ptr、unique_lock ```cpp std::mutex gMutex;

std::unique_lock innerStartLocked() { std::unique_lock lock(gMutex); // can’t do this: // std::unique_lock lock2 = lock; // // … return lock; }

void startProcessA() { auto lock = innerStartLocked(); // process A }

void startProcessB() { auto lock = innerStartLocked(); // process B }

  1. <a name="IORil"></a>
  2. ## 完美转发
  3. 有了右值语义后,还有一个问题,就是右值一旦绑定到变量上,这个变量就是左值了。那么,如果一个函数接收一个右值作为参数,然后又想传递这个参数给另一个接收右值的函数,该怎么办?
  4. <a name="N1F6C"></a>
  5. ### 非模板函数转发
  6. 让我们看看下面的例子:
  7. ```cpp
  8. Foo createFooByBuilder(FooBuilder&& builder);
  9. Foo createFoo(FooBuilder&& builder) {
  10. std::cout << "create foo with builder" <<std::endl;
  11. builder.setPropertyA("A");
  12. createFooByBuilder(builder); // error, builder is now lvalue
  13. createFooByBuilder(std::move(builder)); // ok
  14. }

createFoo接受一个右值,但是一旦右值绑定到右值引用变量,这个右值引用就成为一个左值,后续调用另一个接收右值的函数时,就会报错。这种场景下,需要使用std::move将左值转为右值。
不要乱用std::move,一定要在清楚知道被move后的变量不会再被使用的前提下,再使用std::move。这种绑定了一个即将销毁的右值的变量,在C++标准中有个专门的名字:xvalue,x 表示混合,因为这种变量虽然是lvalue,但绑定的值我们清楚的知道是rvalue。

模板函数转发(完美转发)

再让我们看看模板函数的情况,假如我们想提供一个如下创建shared_ptr的模板函数,在参数是左值时,调用拷贝构造函数,在参数是右值时,调用移动构造函数,我们应该这样做:

  1. template<typename T, typename Arg>
  2. shared_ptr<T> factory(const Arg&& arg) {
  3. return shared_ptr<T>(new T(std::forward<Arg>(arg)));
  4. }

上面,std::forward函数的作用是,如果factory被调用时传入的arg是左值,将arg转为左值引用;如果传入的arg是右值,将arg转为右值引用。这种效果,就是完美转发。

深入理解std::move和std::forward

前面介绍了std::move和std::forward的作用,下面介绍它们的实现。但首先,我们需要了解一些前置知识

左值可以强转为右值

可以使用static_cast将左值强转为右值,std::move封装了这一操作:

  1. void acceptFoo(Foo&& foo);
  2. Foo foo;
  3. acceptFoo(static_cast<Foo&&>(foo)); // ok
  4. acceptFoo(std::move(foo)); // ok, recommended

模板类型推导,引用折叠规则

模板类型推导时,存在引用折叠规则:

  1. template <typename T> // T 是要推导的类型
  2. void f(ParamType param) // ParamType 也是要推导的类型,是个和T有关的类型,
  3. // 比如 void f(T& param),这里T& 就是 ParamType
  4. f(expr) // 调用模板的代码,编译器要为这个调用推导合适的类型
  • 如果ParamType写成 T&& ,而param是:
    • 左值,则ParamType会被推导为 T&
    • 右值,则ParamType会被推导为 T&&
  • 如果ParamType写成 T&,则不论param是左值还是右值,ParamType都会被推导为 T&
  • 如果ParamType写成 T,则不论param是左值还是右值,ParamType都会被推导为T

模板类的偏特化

可以对一个模板类进行偏特化,在选择合适的模板时,编译器会选择和提供的模板类型最匹配的一个。

  1. template<typename T> struct Foo {
  2. using foo = T;
  3. }; // 模板类定义
  4. template<typename T> struct Foo<T&> {
  5. using foo2 = T;
  6. }; // 模板类的偏特化
  7. template<typename T> struct Foo<T&&> {
  8. using foo3 = T;
  9. }; // 模板类的偏特化
  10. using foo = Foo<int>::foo; // ok
  11. using foo2 = Foo<int&>::foo2; // ok
  12. using foo2 = Foo<int&>::foo; // error
  13. using foo3 = Foo<int&&>::foo3; // ok
  14. using foo3 = Foo<int&&>::foo2; // error

模板元编程技巧,remove_reference_t

利用上面的规则:

如果ParamType写成 T,则不论param是左值还是右值,ParamType都会被推导为T

可以编写一个用于萃取无引用类型的模板:

  1. template< typename T > struct remove_reference { typedef T type; };
  2. template< typename T > struct remove_reference<T&> { typedef T type; };
  3. template< typename T > struct remove_reference<T&&> { typedef T type; };
  4. template<typename T>
  5. using remove_reference_t = typename remove_reference<T>::type;

std::move的实现

  1. template<typename T>
  2. remove_reference<T>::type&& move(T&& arg)
  3. {
  4. return static_cast<remove_reference_t<T>&&>(arg);
  5. }

std::forward的实现

  1. template<class S>
  2. S&& forward(typename remove_reference<S>::type& a) noexcept
  3. {
  4. return static_cast<S&&>(a);
  5. }

其他

const reference

C++11之前,const reference也可以接收右值,但是const reference同样可以接收const左值,这是它和右值引用的差异。

返回值优化 RVO

有些看起来会调用move constructor的代码,由于编译器普遍会进行RVO,会避免move constructor调用,不过,这种优化是在不影响代码语义的前提下进行的,如果返回的类对象不支持move constructor,编译器会在编译时就报错。

  1. Foo createFoo() {
  2. Foo foo();
  3. foo.setA("a");
  4. return foo; // 如果foo没有move constructor,clang会报错
  5. }
  6. auto foo = createFoo(); // no move constructor called!