Skip to content

Latest commit

 

History

History
226 lines (182 loc) · 8.15 KB

01_右值引用.md

File metadata and controls

226 lines (182 loc) · 8.15 KB

左值与右值

左值(lvalue)是指表达式结束后依然存在的持久对象,右值(rvalue)是指表达式结束时就不再存在的临时对象。一个区分左值和右值的便捷方法是: 看能不能对表达式取地址(即是否占用存储单元),如果能,则为左值,否则为右值。

纯右值、将亡值和右值引用

C++11 增加了一个新的类型,称为右值引用(R-value reference),标记为 T &&

C++11 的右值概念由两部分构成,一个是将亡值(xvalue, expiring value),另一个则是纯右值(prvalue, PureRvalue)。

非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和 lambda 表达式等都是纯右值。

将亡值是 C++11 为了引入右值引用而提出的概念,也就是即将被销毁、却能够被移动的值。它包括将要被移动的对象、T&&函数返回值、std::move 返回值和转换为 T&& 类型的转换函数的返回值等。

右值引用特性

右值引用就是对一个右值进行引用的类型。因为右值不具名,所以我们只能通过引用的方式找到它。

因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名,所以像左值引用一样,声明右值引用时也必须进行初始化。

通过右值引用的声明,该右值才"重获新生",其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。

左值引用 or 右值引用?

实际上 T&& 未必一定表示右值,它绑定的类型是未定的,既可能是左值又可能是右值。

  template <typename T>
  void f(T&& param);
  
  f(10);          // 10 是右值
  int x = 10;
  f(x);           // x 是左值

像上面这样,当发生自动类型推断时,这个未定的引用类型称为 universal references 。它必须被初始化,它是左值还是右值引用取决于它的初始化,如果 && 被一个左值初始化,它就是一个左值;如果它被一个右值初始化,它就是一个右值。

编译器会将已命名的右值引用视为左值,而将未命名的右值引用视为右值。

示例一:

  int&& var1 = x;
  auto&& var2 = var1;

var1 的类型是 int&&, 它是一个右值引用。var2 存在类型推断,因此是一个 universal references, auto&& 最终被推导为 int&。

示例二:

  void PrintValue(int& i)
  {
    std::cout << "lvalue: " << i << std::endl;
  }
  void PrintValue(int&& i)
  {
    std::cout << "rvalue: " << i << std::endl;
  }
  void Forward(int&& i)
  {
    PrintValue(i);      // 已命名的右值引用被视为左值
  }
  
  // main
  int i = 0;
  PrintValue(i);
  PrintValue(1);
  Forward(2);

输出结果如下:

  lvalue: 0
  rvalue: 1
  lvalue: 2

Forward 函数接收的是一个右值,但在转发给 PrintValue 时又变成了一个左值,因为在 Forward 中调用 PrintValue 时,右值 i 变成了一个命名的对象,编译器会将其当作左值处理。

利用一个左值去初始化一个右值引用类型是非法的,编译会报错。这时如果希望把一个左值赋给一个右值引用类型,可以通过 std::move 解决。

  int w;
  int &rrw1 = w;              // error
  int &rrw2 = std::move(w);   // ok

std::move 可以将一个左值转换成右值。

右值引用性能测试

使用右值引用可以避免无谓的复制,近而提高程序性能。

对于如下测试程序:

  int g_constructCount = 0;
  int g_copyConstructCount = 0;
  int g_destructCount = 0;

  struct A
  {
    A() { std::cout << "coustruct: " << ++g_constructCount << std::endl; }
    A(const A& a) { std::cout << "copy construct: " << ++g_copyConstructCount << std::endl; }
    ~A() { std::cout << "destruct: " << ++g_destructCount << std::endl; }
  };

  A GetA() { return A(); }
  
  // main
  A a = GetA();
  • 如果开启返回值优化,输出结果将是:

      construct: 1
      destruct: 1

    编译器将会把临时对象优化掉,但这不是 C++ 标准,是各编译器的优化规则。

  • 在 GCC 下编译时设置编译选项 -fno-elide-constructors 来关闭返回值优化。输出结果:

      construct: 1
      copy construct: 1
      destruct: 1
      copy construct: 2
      destruct: 2
      destruct: 3

    在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是 GetA() 函数内部创建的对象返回后构造一个临时对象产生的,另一次是在 main 函数中构造 a 对象产生的。多了两次拷贝,也就多了两次 destruct。

  • 优化一: 通过右值引用来绑定 main 函数中返回值:

      A&& a = GetA();

    在编译时设置编译选项 -fno-elide-constructors, 输出结果如下:

      construct: 1
      copy construct: 1
      destruct: 1
      destruct: 2

    通过右值引用,比之前少了一次拷贝和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。

  • 优化二: 紧跟随优化一,通过右值引用来绑定 GetA() 函数中的返回值:

      A&& GetA() { return A(); }

    在编译时会有如下 warning 提示:

      In function ‘A&& GetA()’:
      warning: returning reference to temporary [-Wreturn-local-addr]
      A&& GetA() { return A(); }
                            ^

    通过 std::move 设置移动:

      A&& GetA() { return std::move(A()); }

    在编译时设置编译选项 -fno-elide-constructors, 输出结果如下:

      construct: 1
      destruct: 1

    可以看出,合理的使用右值引用,可以避免无谓的复制,进而提高程序的性能。

    测试程序

利用右值引用优化性能,避免深拷贝

对于含有堆内存的类,如果使用默认构造函数,可能会导致堆内存的重复删除。

  class A
  {
  public:
    A() : m_ptr(new int(0)) { std::cout << "construct" << std::endl; }
    ~A() { std::cout << "destruct" << std::endl; delete m_ptr; }
  private:
    int* m_ptr;
  };

  A GetA() { return A(); }

  // main
  A a = GetA();

上述代码在开启编译器优化后,执行没有问题。但在关闭优化后(在编译时设置编译选项 -fno-elide-constructors)再执行,会有 double free 错误。可以看出,单纯依靠编译器,会存在不安全因素。

上述不安全因素可以通过深拷贝来解决,但有时深拷贝构造却是不必要的,如上述代码的情形。且如果堆内存还在,还会带来额外的性能损耗。可以对类 A 进行下面的优化:

  class A
  {
  public:
    A() : m_ptr(new int(0)) { std::cout << "construct" << std::endl; }
    A(const A& a) :m_ptr(new int(*a.m_ptr)) { std::cout << "copy(deep) construct" << std::endl; }
    A(A&& a) :m_ptr(a.m_ptr) { a.m_ptr = nullptr; std::cout << "move construct" << std::endl; }
    ~A() { std::cout << "destruct" << std::endl; delete m_ptr; }
  private:
    int* m_ptr;
  };

关闭优化后不再报 double free 错误,输出结果如下:

  construct
  move construct
  destruct
  move construct
  destruct
  destruct

程序在执行时会根据传入参数是左值还是右值来选择相应构造函数。如果是临时值,则会选择移动构造函数。 return A() 的返回值和 GetA() 函数的返回值都是临时值,所以选择了移动构造函数。

继续优化: 当然,即便选择了移动构造,但还是调用了两次移动构造,两次析构。继续优化如下:

  A&& GetA() { return std::move(A(); }
  
  // main
  A&& a = GetA();

关闭优化后输出结果如下:

  construct
  destruct

可以看出,通过右值引用,可以对最开始创建的对象一用到底,中间连拷贝都"节省"了。但实际工程中并不会优化到这里,因为 a 是一个右值(将亡值),没有自己的地址,后续也就无法对其进行操作。

需要注意的是,在提供右值引用的构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造。

测试程序