C++右值引用和移动语义

C++右值引用和移动语义

参考:http://thbecker.net/articles/rvalue_references/section_01.html

左值和右值

从左值和右值在早期 C 语言中最基本的定义说起,就像字面上说的,左值就是在出现在赋值等式左边的值,右值就是出现在赋值等式右边的值。

比如int a = 10,其中a就是左值,10就是右值。当然进一步说,左值也可以出现在等式的右边,比如int a, b; a = b;中,b 是左值,但出现在赋值等式右边同样也是合法的。

然而在 C++ 标准中这个分类会来的复杂一些,比如在 C++17 标准中,表达式的值类型除了 lvalue 和 rvalue,还可以进一步进行更细致的分类,如下图:

C++ expression value categories.

不过在现在讨论的这个主题中,应该没必要把值的类型复杂化,这里只要简单把值划分为左值和右值在常见的情况下就够用了。(如果想进一步了解值的分类,请参考:https://docs.microsoft.com/en-us/cpp/cpp/lvalues-and-rvalues-visual-cpp?view=msvc-170)

那么如何判断一个表达式是左值还是右值呢。这里给出一个判断原则,可能不够严谨,但对于后续的讨论会很有帮助:左值通常指向了一块实际的内存空间,我们可以通过 & 符号获得这块内存空间的地址,而不能做到这个操作的就是右值。

常见的左值:

int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue

常见的右值:

int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue

移动语义

移动语义这个名词可能不是很直观,但其实用一个例子就可以解释清楚:

当对一个包含资源的对象进行拷贝操作时,有深拷贝和浅拷贝两种方式,深拷贝需要申请新的资源,而浅拷贝只要把资源指向的指针复制过来。在进行浅拷贝时,如果被复制对象又是一个临时对象,那么实际上连复制都不需要,只要将临时对象里的指针“移动”到另一个对象中就可以了。当我们进行移动操作时,被移动的对象就具备了“移动语义”

具有”移动语义“的对象自然而然具备的特点是,它同时是一个临时对象。因为当这个对象被移动后,其原有资源被移动到另一个对象中,此时任何访问或使用对象的任何方法的行为都是不合理的。在 C++ 中,我们可以粗浅认为区分左值和右值就是为了区分对象是否是临时的,对于右值,其本身就是临时的,可以直接赋予”移动语义“;对于左值,必须先通过std::move()将其转化为一个右值,再赋予移动语义。

我想从我自己的角度讲讲对std::move()的理解。std::move()这个函数名或许有点误导性,虽然他函数名是“移动”,但这个函数唯一做的一件事就是把值转换成右值。从逻辑上讲,右值对于移动语义来说是必要不充分的:一个具备移动语义的对象一定是右值,但右值本身只代表了临时性,并不意味着一定要具备移动语义。虽然在实际使用时,std::move()通常把转化为右值和赋予移动语义两件事绑定在一起完成,但我觉得其中是逻辑上的先后差异的。或者说,“移动语义”是一种抽象的概念,表明该对象具备了将资源”移动“到另一个对象中的能力,而左值或右值则是表达式实实在在具备的性质,不应该把这两者混为一谈。

右值引用

过多纠结概念对于这个问题没什么帮助,回到一开始的问题,移动语义想要解决的问题是,能否可以做到:对于临时对象(右值)进行移动拷贝,对于非临时对象(左值)进行深拷贝。

要做的事情也很简单,需要实现两个拷贝赋值函数,一个负责深拷贝,一个负责移动拷贝,其中深拷贝函数对应的参数是左值(当参数是左值时,调用深拷贝函数),移动拷贝函数对应的参数是右值(当参数是右值时,调用移动拷贝函数)。

首先我们可以尝试一下,在不使用右值引用的情况下能不能做到这个要求?首先定义一个测试类,类中包含了一个 vector 指针:

class TestClass {
   public:
    explicit TestClass(std::vector<int> * vec): resource(vec) {}
    TestClass(): resource(nullptr) {}
    TestClass(const TestClass &) = default;
    TestClass& operator=( ??? ) { // 接受左值参数,进行深拷贝
      // deep copy resource
      if (resource != nullptr) {
        delete resource;
      }
      resource = new std::vector<int>(*rhs.resource);
      std::cout << "Copy Assignment." << std::endl;
      return *this;
    }

    TestClass& operator=( ??? ) { //接受右值参数,进行浅拷贝
      // swap resource pointer
      swap(resource, rhs.resource);
      std::cout << "Move Assignment." << std::endl;
      return *this;
    }
   private:
    std::vector<int> * resource;
};

考虑参数里的两个???应该填入什么,能够满足我们的传参要求,首先传参一定是以引用形式,那么可以填入的选择只有以下两种:

或许可以发现一种方案:在深拷贝参数中使用左值引用形式TestClass & rhs,在移动拷贝参数中使用常量引用形式const TestClass &rhs。当传入左值时,编译器优先选择参数为Test Class &rhs的函数进行深拷贝,当传入右值时,选择参数为const Test Class &rhs的函数进行移动拷贝。

但此时会发现swap(resource, rhs.resource)这一句报错,因为右值被绑定在const上,无法对其进行修改。但理论上这个右值是一个临时变量,在语义上应当是可以被修改的,不应该被const修饰,这里相当于由于语法上的缺陷不得不加上了这个const

而右值引用则可以很好地解决这个问题,在传参时增加右值引用的写法:TestClass && rhs,这种形式传参只能用于接受右值。

现在得到的新方案是:在深拷贝参数中使用常量引用const TestClass &rhs,在移动拷贝参数中使用右值引用TestClass && rhs。当传入右值时,编译器优先选择参数为Test Class && rhs的函数进行深拷贝,当传入左值时,选择参数为const TestClass &rhs的函数进行深拷贝,也就是:

TestClass& operator=(const TestClass &rhs) {
      // deep copy resource
      if (resource != nullptr) {
        delete resource;
      }
      resource = new std::vector<int>(*rhs.resource);
      std::cout << "Copy Assignment." << std::endl;
      return *this;
    }
TestClass& operator=(TestClass && rhs) {
    // swap resource pointer
    swap(resource, rhs.resource);
    std::cout << "Move Assignment." << std::endl;
    return *this;
}

测试一下,发现可以正常运作:

TestClass GetTestClass() {
  TestClass tc(new std::vector<int>);
  return tc;
}
int main() {
  TestClass tc;
  TestClass tc2(new std::vector<int>);
  tc = tc2;
  std::cout << "==================" << std::endl;
  tc = GetTestClass();
  std::cout << "==================" << std::endl;
  TestClass tc3(new std::vector<int>);
  tc = std::move(tc3);
  return 0;
}
---------output----------
Copy Assignment. // tc = tc2, 深拷贝
==================
Move Assignment. // tc = GetTestClass(), 由于 GetTestClass() 是一个右值,进行移动拷贝
==================
Move Assignment. // tc = std::move(tc3), 由于 std::move(tc3) 是一个右值,进行移动拷贝

于是通过引入右值引用解决了对左值右值参数分别处理的问题。说到底,在这个问题里右值引用只做了一件事:让编译器能够在运行时能够回答一个问题:“这个函数是被左值调用了还是被右值调用了?”,在此基础上解决了深拷贝和移动拷贝的分支问题。

相比较而言,不使用右值引用的方案的确也能回答这个问题,但缺陷就是在没有必要的情况下引入了一个const

换言之,右值引用是一个桥梁,衔接了右值和移动语义之间的联系(右值 -> 右值引用使编译器能够调用移动拷贝函数 -> 赋予移动语义)。

所以说右值引用使得 C++ 实现了移动语义。此外,右值引用解决的另一个问题是完美转发,此处暂时不作讨论。

如何使用 std::move

之前说到,std::move唯一的作用是将一个值转化为右值。如果从抽象的角度理解,当然可以把std::move作为一种移动语义的实现,但我还是认为应当把整个实现逻辑理解为:std::move将值转化为右值 -> 右值引用使编译器能够分辨出右值并调用移动拷贝函数 -> 赋予对象移动语义

从使用的角度来说,典型的例子是swap函数,在不使用std::move时, swap可以实现如下:

template<class T>
void swap(T& a, T& b) { 
  T tmp(a);
  a = b; 
  b = tmp; 
}

这样的实现当然是正确的,但也是效率低下的。此处的tmpab都是左值,通常来说左值对应的拷贝赋值函数都是深拷贝,但在swap这个例子中,深拷贝是不需要的,因为可以发现每次进行赋值后,被赋值的对象就再也不会被使用了。在这个前提下,可以在每次进行拷贝赋值前先将参数转化为右值,也就是:

template<class T> 
void swap(T& a, T& b) { 
  T tmp(std::move(a));
  a = std::move(b); 
  b = std::move(tmp);
}

而这也是标准库中std::swap的实现。通过std::move转化为右值后,会调用参数为右值的拷贝赋值函数,而这个函数的实现通常是移动拷贝。不仅仅是std::swap,标准库中很多其他的算法也会尽可能使用std::move来提升效率。同时一些 STL 原本要求容器具有可复制的特性,有了移动语义之后则可以把要求降低为只要具有可移动的特性即可,比如std::auto_ptr只能满足可移动而不能满足可复制,但仍然可以作为许多 STL 的容器,而在 C++11 之前,由于没有移动语义的概念,编译器则会禁止将std::auto_ptr作为容器的行为。

值得一提的是,从本质上来说std::move本身并不会带来性能提升,带来性能提升的是从深拷贝改为移动拷贝。也就是说,如果传入的类并没有实现移动拷贝函数,而是只给出了唯一一种深拷贝函数,比如使用深拷贝实现的A& operator=(const A & rhs),那么就算使用了std::move,如果最终调用的拷贝函数没有任何区别,性能是不会变化的。不过通常来说,标准库中的类型都给出不同的拷贝函数来优化性能,在我们自己实现类时也应当注意区分深拷贝和移动拷贝的情况。

使用std::move时还需要注意的一点是移动拷贝行为是否会影响到某些资源释放的时机,因为很多时候资源释放与对象的生命周期相关,比如说下面这种写法:

a = std::move(b);

如果a的移动拷贝函数是通过swap实现的(比如上面的TestClass中的实现),逻辑上在这一句执行后,对象a原本的资源即应当被认为释放或销毁,但实际上资源释放不会立刻发生,而是取决于对象b的生命周期,通常会在b离开其作用域的时候通过调用析构函数来释放资源,但这可能就不符合原来的设计了。内存释放的时机相对来说没有那么重要(只要保证没有内存泄漏),但是其他资源,特别是锁,在准确的时机释放有时就很重要了(否则可能会导致死锁之类的情况发生),有时候需要在a的移动拷贝函数中对这些资源进行销毁和释放,也就是像下面这样写:

X& X::operator=(X&& rhs) {
  // Perform a cleanup that takes care of at least those parts of the
  // destructor that have side effects. Be sure to leave the object
  // in a destructible and assignable state.

  // Move semantics: exchange content between this and rhs
  return *this;
}

不过这也只能算是个特例,关键还是掌握std::move的作用是将值转化为右值,熟悉这种转换对后续逻辑可能产生的影响(影响调用的拷贝函数类型,不同类型的拷贝函数会有不同的具体实现方式),结合对象生命周期和资源的释放时机需求进行分析即可。

右值引用的特性

这里要讨论的问题是,右值引用是左值还是右值?对于这个问题的判断原则是这样的:如果右值引用有名字则是左值,如果没有名字则是右值。

回顾之前我们对左值和右值的认识,右值表示一个临时值,左值表示一个非临时值。这种设计的合理性在于,“移动语义”和可访问性是不能共存的。

试想这个情形,假设x是一个右值,那么下面这句赋值很可能调用的是移动拷贝:

X anotherX = x;  // x is still in scope!
// do something about x...

移动拷贝后,对象x的状态是不确定的,无论是置空还是与anotherX互换。但x仍然在作用域内,再对x的任何访问操作都非常危险。

而且这种移动可能会发生地非常隐蔽,导致错误更难发现。总之可以从中总结出一个新的原则:有名字的值一定是左值。

这会导致的一个常见错误是,如果希望在派生类和基类中都使用移动语义,那么需要在中间步骤使用std::move

Derived(Derived&& rhs) 
  : Base(rhs) {} // wrong: rhs is an lvalue

Derived(Derived&& rhs) 
  : Base(std::move(rhs)) {} // good, calls Base(Base&& rhs)

根据是否有名字判断左右值的原则对于std::move也是适用的,虽然std::move可以将一个值转换成右值,但一旦用一个有名字的对象来接这个右值,它就会立刻转变为左值。从这个意义讲,std::move正是通过隐藏名字的方式将值转化为右值的。

#include <iostream>

void foo(int & i) {
  std::cout << "lvalue ref" << std::endl;
}

void foo(int && i) {
  std::cout << "rvalue ref" << std::endl;
}

int main() {
  auto a = std::move(1);
  foo(a);
  foo(std::move(1));
  return 0;
}
===output===
lvalue ref
rvalue ref

编译器返回值优化

在理解了移动语义是如何与移动赋值/构造函数结合来提高性能后,你可能会有一种优化函数返回值的想法,比如下面这个函数:

class A {
 public:
  A() {
    std::cout << "Default Constructor." << std::endl;
  }
  A(const A &) {
    std::cout << "Copy Constructor." << std::endl;
  }
  A(A &&) {
    std::cout << "Move Constructor." << std::endl;
  }
};

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

看起来函数会先构造一个局部变量,然后进行一次深拷贝,将局部变量拷贝到返回值上。变量a是一个局部变量,从这个角度讲其实使用std::move将其转化为右值,并利用移动语义优化拷贝,也就是改成:

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

这个想法当然是好的,但是在实际情况下并不会产生优化,反而会使效率下降。其原因在于编译器会使用 return value optimization (ROV) 对这种情况进行优化。当返回值是一个局部变量,并与返回值数据类型一致时,编译器不会通过拷贝的方式构造返回值,而是直接选择在构造局部变量时,直接在返回值的位置上进行构造,从而减少了一次拷贝的代价(无论是深拷贝还是浅拷贝)。当我们将返回值改成std::move(a)时,反而破坏了达成这种优化的前提条件,额外多了一次移动拷贝行为。

相似的情况是 named return value optimization (NROV),当函数的返回值被直接储存在一个持久化的变量中时,同样会直接在该变量的位置构造对象,免去拷贝赋值/构造的代价。对于这种优化情况,可以用下面的例子简单理解:

不使用移动语义,使用 NROV 优化,只调用了一次默认构造函数(直接在a2的位置构造),且函数中局部变量amain函数中的a2地址相同:

A GetA() {
  A a;
  std::cout << &a << std::endl;
  return a;
}

int main() {
  A a2(GetA());
  std::cout << &a2 << std::endl;
  return 0;
}

========output=========
Default Constructor.
0x7ffe1b37a8d7
0x7ffe1b37a8d7

使用移动语义,无法使用 NROV 优化,此时GetA()调用了一次默认构造函数,main()中调用一次移动构造函数 ,两处内存地址不同:

A GetA() {
  A a;
  std::cout << &a << std::endl;
  return std::move(a);
}

int main() {
  A a(GetA());
  std::cout << &a << std::endl;
  return 0;
}

========output=========
Default Constructor.
0x7fff1052bd17
Move Constructor.
0x7fff1052bd37

也可以使用编译参数-fno-elide-constructors禁止 ROV / NROV 优化,此时也和返回std::move(a)的情况一致(即使没有显式使用std::move)。似乎说明当禁用 ROV / NROV 优化后,编译器会退而求其次使用移动语义的方式进行优化。

总之,由于 ROV / NROV 优化的存在,在参数返回值中使用 std::move 可能反而会产生负面作用。不过也要分情况,比如函数的返回值不是局部变量,而是某个全局变量,那么此时不满足 ROV / NROV 优化的条件,如果能够确定此情况满足移动语义,也是可以使用std::move来优化的,只是这种情况极少出现,因为赋予这个全局变量移动语义后,意味着这个全局变量此后的访问都是不可靠的,通常来说不会做到这种程度。

总结

个人感觉C++11 中引入的std::move、右值引用等一系列特性,更像是对旧标准下一些缺陷的补救。其中最主要解决的就是移动语义和完美转发两个问题。此处主要分析了如何通过引入函数调用分支的方式提供移动语义,解决左右值函数调用,以及相应的拷贝函数代价的问题,最终提升效率。