C++右值引用和移动

Attention:this blog is a translation of https://www.internalpointers.com/post/c-rvalue-references-and-move-semantics-beginners ,which is posted by @internalpoiners.

一、前言

在我的前一篇文章里,我解释了右值背后的逻辑。核心的思想就是:在C++中你总会有一些临时的、生命周期较短的值,这些值无论如何你都无法改变。
令人惊喜的是,现代C++(通常指C++0x或者更高的版本)引入了右值引用(rvalue reference)的概念:它是一个新的可以被绑定到临时对象的类型,允许你改变他们。为什么呢?


让我们先看看下面的代码:

int x = 666;                    // (1)
int y = x + 5;                  // (2)

std::string s1 = "hello ";
std::string s2 = "world";
std::string s3 = s1 + s2;       // (3)

std::string getString() {
  return "hello world";
}
std::string s4 = getString();   // (4)

在(1)处,字面常量(literal constant)666是一个右值:它没有具体的内存地址,除了程序运行时的一些临时寄存器,它需要被存储在左值x中留待使用。在(4)处也有着类似的情况,但是这里右值不是硬编码(hard-coded)的,而是由函数getString()返回的。然而,与(1)处一样,这个临时值也需要被存储在一个左值s4中,留待将来使用。
(2)和(3)处看上去更微妙一些:编译器创建了一个临时对象来存放+操作符的结果,作为一个临时值,输出毫无疑问是一个必须被存放在某处的右值。在这里,我分别将结果放入到ys3中。

二、右值引用的魔力

传统的C++规则规定:只有存储在const变量(immutable)中的右值才能获取它的地址。从技术上来说,你可以将一个const lvalue绑定(bind)到一个rvalue上。看下面的代码:

int& x = 666;       // Error
const int& x = 666; // OK

第一个操作是错误的,它是一个使用int类型的右值来初始化non-constint&类型的非法操作。第二个操作正确,当然,x是一个常量,你不能改变他。(译者按:注意,常量引用只是规定无法通过该引用改变引用的对象,如果该对象有其他非常量引用或者指针,通过这些改变该对象仍然是合法的)
C++ 0x引入了一个新的类型——右值引用(rvalue reference),通过在类型名后放置&&来表示右值引用。这些右值引用让你可以改变一个临时对象的值,看上去好像他去掉了上面第二行中的const了一样。
让我们用这个新玩具来玩耍一番:

std::string   s1     = "Hello ";
std::string   s2     = "world";
std::string&& s_rref = s1 + s2;    // the result of s1 + s2 is an rvalue
  s_rref += ", my friend";           // I can change the temporary string!
std::cout << s_rref << '\n';       // prints "Hello world, my friend"

这里我们创建了两个简单的字符串s1s2,我将它们连接并把结果放入std::string&& s_rref中。现在s_rref是一个对于临时对象的一个引用,或者称之为右值引用。这个引用没有const修饰,所以我可以根据需求随意修改他而不需要付出任何代价。如果没有右值引用和&&符号,想要完成这一步是不可能的。为了更好地区分右值引用和一般引用,我们将传统的C++引用称作左值引用(lvalue reference)。
乍一看右值引用毫无用处,然而它为移动语义(move semantics)的实现做了铺垫,移动语义可以先出提升你的应用的表现。

三、移动语义——风景秀丽的路线

移动语义(以下简称move)是一个最佳移动资源的方法,它避免了不必要的历史对象的拷贝,这些都是基于右值引用的。在我看来,理解什么是move最好的方法就是构建一个动态资源(即动态分配的指针)的包装类(wrapper class)并且观察该类的对象被移入移出函数时发生了什么。记住,move不只是用于类!
让我们来看下面的例子:

class Holder
{
  public:

    Holder(int size)         // Constructor
    {
      m_data = new int[size];
      m_size = size;
    }

    ~Holder()                // Destructor
    {
      delete[] m_data;
    }

  private:

    int*   m_data;
    size_t m_size;
};

这是一个处理动态内存块的类,除了动态内存分配(allocation)部分之外没什么特别的。当你选择自己管理内存时你需要遵守所谓的rule of three。规则如下:如果你的类定义了下面所说的方法中的一个或者多个,它最好显式定义所有的三个方法:

  • 析构函数(destructor)
  • 拷贝构造函数(copy constructor)
  • 拷贝复制运算符(copy assignment operator)

(如果你不定义这些函数)C++的编译器会以默认的方式生成这些函数以及构造函数和其他我们现在没有考虑的函数。不幸的是,默认的函数对于处理动态资源是完全不够的。实际上,编译器无法生成向上面那样的构造函数,因为它不知道我们的类的逻辑。

1)实现拷贝构造函数

让我们先依照Rule of Three并实现拷贝构造函数。正如你所知道的,拷贝构造函数从另外一个已经存在的对象来构造新的对象,例如:

Holder h1(10000); // regular constructor
Holder h2 = h1;   // copy constructor
Holder h3(h1);    // copy constructor (alternate syntax)

一个拷贝构造函数可能长成这样:

Holder(const Holder& other)
{
  m_data = new int[other.m_size];  // (1)
  std::copy(other.m_data, other.m_data + other.m_size, m_data);  // (2)
  m_size = other.m_size;
}

这里我使用一个已经存在的对象other来初始化一个新的Holder对象,我创建了一个同样大小的数组并且我将other里面m_data的数据拷贝到this.m_data中。

2)实现赋值运算符

现在我们来实现赋值运算符,它用于将一个已存在的对象替换为另一个已存在的对象。例如:

Holder h1(10000);  // regular constructor
Holder h2(60000);  // regular constructor
h1 = h2;           // assignment operator

一个赋值运算符的定义可能长这样:

Holder& operator=(const Holder& other) 
{
  if(this == &other) return *this;  // (1)
  delete[] m_data;  // (2)
  m_data = new int[other.m_size];
  std::copy(other.m_data, other.m_data + other.m_size, m_data);
  m_size = other.m_size;
  return *this;  // (3)
}

首先(1)处避免了将自己赋值给自己(self-assignment),既然我们要用另一个对象来替换当前的对象,我们需要清除当前对象中所有的数据(2),剩下的就和拷贝构造函数中的一样了。按照惯例,我们返回该对象的引用。
拷贝构造函数和赋值运算符的关键点就是它们都接受一个const的对象的引用作为参数并且生成了一个它们所属类的一个副本。
输入的对象时常量引用,当然无法改变!

四、现有类设计的限制

我们的类类很好,但是它缺少一些优化。考虑下面的函数:

Holder createHolder(int size)
{
  return Holder(size);
}

它用传值的方式返回了一个Holder对象。我们知道,当函数返回一个值时,编译器会创建一个临时且完整的对象(右值)。现在,我们的Holder是一个重量级(heavy-weight)的对象,因为它有着内部的内存分配,这是一个相当费事的任务——以现有的类设计返回这些东西的值会导致多次内存分配,这并不是一个好主意。如何得出这个结论?让我们看下面的代码:

int main()
{
  Holder h = createHolder(1000);
}

createHolder()创建的临时对象被传入拷贝构造函数中,根据我们现有的设计,拷贝构造函数通过拷贝临时对象的数据分配了它自己的m_data指针。这里有两次内存分配:

  • 创建临时对象
  • 拷贝构造函数调用

同样的拷贝过程发生在赋值操作符中:

int main()
{
  Holder h = createHolder(1000); // Copy constructor
  h = createHolder(500);         // Assignment operator
}

我们的赋值运算符清除了对象的内存,然后通过从临时对象中拷贝数据,为赋值的对象从头开始分配新的内存。在这里也有两次内存分配:

  • 临时对象创建
  • 调用赋值运算符

拷贝的次数太多了!我们已经有了一个完整的(fully-fledged)临时对象,它由createHolder()函数创建。它是一个右值,如果在下一个指令前不被使用将会消失。所以为什么在构造或者复制时我们不使用move而是选择重复的拷贝呢?
在上古C++中,我们没办法做这样的优化,返回一个重量级对象的值是无用的。幸运的是,在C++11后,我们可以(并且鼓励)使用move来优化我们的类。简而言之,我们将从现有的对象处偷取他们的数据而不是做一些毫无意义的克隆。不要拷贝,总是使用move,因为移动的代价更加的低。

五、用右值引用实现move semantics

让我们用move来为我们的类增光添彩!我们的想法就是增加新的版本的拷贝构造函数和赋值运算符,这样我们就可以将临时对象的数据直接偷过来。“偷”的意思是改变对象中数据的拥有者,我们怎么修改一个临时变量呢?当然是使用右值引用!
在这里我们通常遵守另一个C++规则——Rule of Five。它是Rule of Three的扩展,额外声明了一个规则:任何需要move的类都要声明两个额外的成员函数:

  • 移动构造函数(move constructor):通过从临时对象偷取数据来构建一个新的对象
  • 移动赋值运算符(move assignment operator):通过从临时对象偷取数据来替换已有对象的数据

1)实现移动构造函数

一个典型的移动构造函数:

Holder(Holder&& other)     // <-- rvalue reference in input
{
  m_data = other.m_data;   // (1)
  m_size = other.m_size;
  other.m_data = nullptr;  // (2)
  other.m_size = 0;
}

它使用一个右值引用来构造Holder对象,关键部分:作为一个右值引用,我们可以修改它,所以让我们先偷他的数据(1),然后将它设置为nullptr(2)。这里没有深层次的拷贝,我们仅仅移动了这些资源。将右值引用的数据设置为nullptr是很重要的,因为一旦临时对象走出作用域,它就会调用析构函数中的delete[] m_data,记住了吗?通常来说,为了让代码看上去更加的整洁,最好让被偷取的对象的数据处于一个良好定义的状态。

六、实现移动赋值运算符

移动赋值运算符有着同样的逻辑:

Holder& operator=(Holder&& other)     // <-- rvalue reference in input  
{  
  if (this == &other) return *this;

  delete[] m_data;         // (1)

  m_data = other.m_data;   // (2)
  m_size = other.m_size;

  other.m_data = nullptr;  // (3)
  other.m_size = 0;

  return *this;
}

我们先清理已有对象的数据(1),再从其它对象处偷取数据(2)。别忘了把临时对象的数据设置为正确的状态!剩下的就是常规的赋值运算所做的操作。
既然我们有了新的方法,编译器就会检测你到底是在使用临时对象(右值)创建一个对象还是使用常规的对象(左值),并且它会根据检测的结果触发更加合适的构造函数(或者运算符)。例如:

int main()
{
  Holder h1(1000);                // regular constructor
  Holder h2(h1);                  // copy constructor (lvalue in input)
  Holder h3 = createHolder(2000); // move constructor (rvalue in input) (1) 

  h2 = h3;                        // assignment operator (lvalue in input)
  h2 = createHolder(500);         // move assignment operator (rvalue in input)
}

七、何时、何处使用move semantics

move提供了一个更加智能的传递重量级对象的方法。你只需要创建你的重量级资源一次然后再任何需要的地方移动即可。就像我之前说的,move不只是用于类,只要在你需要改变一个资源的拥有者时都可以使用move。**记住,跟指针不一样的是,move不会分享任何东西,如果对象A从对象B中偷去了数据,对象B中的数据就不再存在了,因此也就不再合法了。我们知道在处理临时对象时这没有问题,但是在从常规对象身上偷取数据时就需要慎重了。

1)我尝试了你的代码:移动构造函数从来没有被调用!

你是对的,如果你运行上面的最后一个代码,你会注意到移动构造函数在(1)处没有被调用,常规的构造函数被调用了。这是因为一个被称作Return Value Optimization(RVO)的技法。现代编译器能够检测出你返回了一个对象的值,并且为此应用一种返回的快捷方式来避免无意义的拷贝。
你可以让编译器不使用这个优化。例如,GCC支持fno-elide-constructors标记,用这个标记来编译程序将使得构造函数和析构函数的调用次数明显提高。

2)为什么有了RVO我们还需要自己实现move semantics?

RVO仅仅针对返回值(输出),不包括函数参数(输入)。有许多地方你会将可移动的对象作为输入参数传入函数,这时候就是移动构造函数和移动赋值运算符发挥作用的时候了。标准库(Standard Library)在升级到C++11后,所有的算法和容器都被扩展以支持move。所以如果你使用符合Rule of Five的类和标准库,你将会获得重要的优化提升。

八、我可以移动左值吗?

是的,通过标准库中的工具函数std::move,你可以移动左值。它被用来将左值转化为右值,假设我们想要从一个左值盗取数据:

int main()
{
  Holder h1(1000);     // h1 is an lvalue
  Holder h2(h1);       // copy-constructor invoked (because of lvalue in input)
}

由于h2接收了一个左值,拷贝构造函数被调用。我们需要强制调用移动构造函数从而避免无意义的拷贝,所以我们这样做:

int main()
{
  Holder h1(1000);           // h1 is an lvalue
  Holder h2(std::move(h1));  // move-constructor invoked (because of rvalue in input)
}

在这里,std::move将左值h1转化为一个右值:编译器看见输入变成了右值,所以调用了移动构造函数。h2将会在构造时从h1处偷取数据。

九、最终的笔记和可能的提升

这篇文章很长但是我仅仅抓住了move的表象。下面列出的是我会在未来深入研究的额外概念。

1)在基础的Holder类中我们使用了RAII

Resource Acquisition Is Initialization(RAII)是一个C++技术,你可以在资源(文件、socket、数据库连接、分配的内存等)周围包装类。这些资源可以在类的构造函数中初始化并在类的析构函数中清除,这会避免资源泄露。

2)用noexcept标记你的移动构造函数和移动赋值运算符

C++11关键词noexcept表示这个函数不会抛出异常。一些人认为移动构造函数和移动赋值运算符永远不要抛出异常。这是合理的,因为除了复制数据和和设置nullptr之外(这些都是不会抛出异常的操作)不需要分配内存或者做其它工作。

3)使用copy-and-swap的更深入的优化和更好的异常安全性

Holder中所有的构造函数和赋值运算符都充满了重复的操作,这不是很好。此外,如果在拷贝运算符中进行(内存)分配时,如果抛出了异常,那么源对象就会变成一个不好的状态。copy-and-swap解决了这两个问题,但是增加了一个新方法。

4)perfect forwarding

这项技术允许你在多个模板和非模板函数之间移动数据,而不需要强类型转换。

来源:https://www.jianshu.com/p/31cea1b6ee24

赞赏

微信赞赏支付宝赞赏

其他

发表评论

电子邮件地址不会被公开。 必填项已用*标注