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

你可能还喜欢下面这些文章

C++动态内存管理

C++中,动态内存管理是通过一对运算符来完成:new 和 delete。new操作符在内存中为对象分配空间并返回一个指向该对象的指针,delete接收一个动态对象的指针,销毁该对象,并释放与之相关的内存。手动管理内存看起来只有这两个操作,似乎很轻松,但实际上这是一件非常繁琐的事情,分配了内存但没有释放内存的场景发生的概率太大了!回想一下,你有多少次打开抽屉却没关上,拿出来的护肤品擦完脸之后却忘了放回去,吃完饭却忘了洗碗。类似这种没有收尾的事情我做的太多了。(以上这些都是在实际生活中我爱人批评我的点)我连这种明面上的事情都能忘记收尾,何况分配内存!所以为了世界和平,我放弃了手动管理内存。好在C+

布隆过滤器(bloom filter)介绍以及php和redis实现布隆过滤器实现方法

引言在介绍布隆过滤器之前我们首先引入几个场景。场景一在一个高并发的计数系统中,如果一个key没有计数,此时我们应该返回0。但是访问的key不存在,相当于每次访问缓存都不起作用了。那么如何避免频繁访问数量为0的key而导致的缓存被击穿?有人说, 将这个key的值置为0存入缓存不就行了吗?这是确实是一种解决方案。当访问一个不存在的key的时候,设置一个带有过期时间的标志,然后放入缓存。不过这样做的缺点也很明显:浪费内存和无法抵御随机key攻击。场景二在一个黑名单系统中,我们需要设置很多黑名单内容。比如一个邮件系统,我们需要设置黑名单用户,当判断垃圾邮件的时候,要怎么去做。比如爬虫系统,我们要记录下

iterm2 使用 rz、sz 的方法

如果没有额外的设置,iterm2 使用 rzsz 的时候会卡在这个时候就需要使用iterm2提供的trigger来实现rzsz的功能。第一步:本机安装rzsz使用rzsz之前本地也需要安装如果没有安装brew,请先安装brew,mac必备的包管理器!第二步:创建发送和接收脚本发送文件的脚本如下,可以复制下面的内容,保存在 /usr/local/bin/iterm2-send-zmodem.sh中。接收文件的脚本如下,同样可以复制保存在/usr/local/bin/iterm2-recv-zmodem.sh第三步:设置Triggerteigger需要设置两个,一个实发送文件的trigger,一个

gcc/g++编译参数详解

编译步骤gcc 与 g++ 分别是 gnu 的 c & c++ 编译器。gcc/g++ 在执行编译工作的时候,总共需要4步:预处理,生成 .i 的文件将预处理后的文件转换成汇编语言, 生成文件 .s 有汇编变为目标代码(机器代码)生成 .o 的文件连接目标代码, 生成可执行程序 参数详解-x language filename参数含义为指定文件所使用的语言。根据约定,C语言的后缀名称为".c",而 C++ 的后缀名为".cpp"或".cc",但如果你的源代码后缀不约定的那几种,那么需要使用-x参数来指定文件所使用的语言。这个参数对他后面的文件名都起作用。 可以使用的参数吗有下面的这些:

使用sublime+platuml高效画图

程序员难免要经常画流程图,状态图,时序图等。以前经常用 visio 画,经常为矩形画多大,摆放在哪等问题费脑筋。有时候修改文字后,为了较好的显示效果不得不再去修改图形。今天介绍的工具是如何使用 Sublime + PlantUML 的插件画流程图,状态图,时序图等。这是一种程序员看了就会爱上的画图方式:自然,高效。什么是 PlantUMLPlantUML 是一个画图脚本语言,用它可以快速地画出:时序图流程图用例图状态图组件图简单地讲,我们使用 visio 画图时需要一个一个图去画,但使用 PlantUML 只需要用文字表达出图的内容,然后就可以直接生成图片。看一个最简单的例子:软件安装这些软件

理解C++中的左值和右值

Attention:this blog is a translation of https://www.internalpointers.com/post/understanding-meaning-lvalues-and-rvalues-c ,which is posted by @internalpoiners.一、前言一直以来,我都对C++中左值(lvalue)和右值(lvalue)的概念模糊不清。我认为是时候好好理解他们了,因为这些概念随着C++语言的进化变得越来越重要。二、左值和右值——一个友好的定义首先,让我们避开那些正式的定义。在C++中,一个左值是指向一个指定内存的东西。另一方

查看varnish统计信息命令:varnishstat

varnishstat是一个查看当前varnish实例的实时运行状态信息。命令以及参数如下:varnishstat 以下选项可用: -1不再显示不断更新的显示,而是将统计信息打印到stdout。-f <glob>Field inclusion glob. Use backslash to escape characters. If the argument starts with '^' it is used as an exclusion glob. Multiple -f arguments may be given, and they will be ap

centos7系统初初始化工作以及网站环境搭建(php7+nginx+mysql)

拿到一台做网站的主机, 我们先要做一些环境初始化的工作, 由于这些工作会有些繁琐,因此记录一下. 后面将这些流程写成一个shell脚本,一次性完成.此次工作流程如下: 安全性设置 额外的目录创建 网站环境搭建安全性设置一般从某云上买的主机, 默认账户是root, 为了不被暴力破解, 我们首先需要设置一个强一点的密码,不过更好的方法是禁用root, 另外创建一个用户来作为日常管理的账户.第一步: 创建一个新的账户,并且能够切换到root权限比如我的用户名叫xiaobai, 添加用户名就是useradd xiaobai设置密码passwd xiaobai之后输入密码,一个新的账户就设定好了.

linux shell 入门

从程序员的角度来看, Shell本身是一种用C语言编写的程序,从用户的角度来看,Shell是用户与Linux操作系统沟通的桥梁。用户既可以输入命令执行,又可以利用 Shell脚本编程,完成更加复杂的操作。在Linux GUI日益完善的今天,在系统管理等领域,Shell编程仍然起着不可忽视的作用。深入地了解和熟练地掌握Shell编程,是每一个Linux用户的必修 功课之一。Linux的Shell种类众多,常见的有:Bourne Shell(/usr/bin/sh或/bin/sh)、Bourne Again Shell(/bin/bash)、C Shell(/usr/bin/csh)、K Shel

Go入门:六、常用标准库

这是我的Go学习的第六篇笔记,也是Go入门的最后一篇笔记。在大多数语言中,了解了变量和数据类型,流程控制,函数,面向对象,再加上标准库,就可以用这门语言去写一些项目了。首先让我想想,在工作中通常会用语言频繁处理什么问题或者处理什么数据?最常见的应该是各种字符串操作,日期和时间,读写文件、socket等IO相关的操作!字符串处理 — StringsString提供了一组处理字符串的操作,常用的有:判断一个字符串是否在另一个字符串中分割字符串为[]string和组合[]string为一个字符串字符串替换...太多了,就不一一列举了,这里列出一些常用的字符串操作。字符串判断字符串分割与合并字符串转换

赞赏

微信赞赏支付宝赞赏

其他

发表回复

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