C++11移动构造函数详解

C++11右值引用》一节中,给读者详细介绍了 C++ 右值引用的含义和用法,同时还提到“右值引用主要用于实现移动(move)语义和完美转发”。有关完美转发,后续章节会做详细介绍,本节主要讲解移动语义的含义以及实现它的方式。

C++11移动语义是什么

在 C++ 11 标准之前(C++ 98/03 标准中),如果想用其它对象初始化一个同类的新对象,只能借助类中的复制(拷贝)构造函数。通过《C++拷贝构造函数》一节的学习我们知道,拷贝构造函数的实现原理很简单,就是为新对象复制一份和其它对象一模一样的数据。

需要注意的是,当类中拥有指针类型的成员变量时,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制该指针成员。有关深拷贝和浅拷贝以及它们的区别,读者可阅读《C++深拷贝和浅拷贝》一文做详细了解。

举个例子:
#include <iostream>
using namespace std;

class demo{
public:
   demo():num(new int(0)){
      cout<<"construct!"<<endl;
   }
   //拷贝构造函数
   demo(const demo &d):num(new int(*d.num)){
      cout<<"copy construct!"<<endl;
   }
   ~demo(){
      cout<<"class destruct!"<<endl;
   }
private:
   int *num;
};

demo get_demo(){
    return demo();
}

int main(){
    demo a = get_demo();
    return 0;
}

如上所示,我们为 demo 类自定义了一个拷贝构造函数。该函数在拷贝 d.num 指针成员时,必须采用深拷贝的方式,即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。否则一旦多个对象中的指针成员指向同一块堆空间,这些对象析构时就会对该空间释放多次,这是不允许的。

可以看到,程序中定义了一个可返回 demo 对象的 get_demo() 函数,用于在 main() 主函数中初始化 a 对象,其整个初始化的流程包含以下几个阶段:
  1. 执行 get_demo() 函数内部的 demo() 语句,即调用 demo 类的默认构造函数生成一个匿名对象;
  2. 执行 return demo() 语句,会调用拷贝构造函数复制一份之前生成的匿名对象,并将其作为 get_demo() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁);
  3. 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的对象会被析构);
  4. 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。

注意,目前多数编译器都会对程序中发生的拷贝操作进行优化,因此如果我们使用 VS 2017、codeblocks 等这些编译器运行此程序时,看到的往往是优化后的输出结果:

construct!
class destruct!

而同样的程序,如果在 Linux 上使用g++ demo.cpp -fno-elide-constructors命令运行(其中 demo.cpp 是程序文件的名称),就可以看到完整的输出结果:

construct!                <-- 执行 demo()
copy construct!       <-- 执行 return demo()
class destruct!         <-- 销毁 demo() 产生的匿名对象
copy construct!       <-- 执行 a = get_demo()
class destruct!         <-- 销毁 get_demo() 返回的临时对象
class destruct!         <-- 销毁 a

如上所示,利用拷贝构造函数实现对 a 对象的初始化,底层实际上进行了 2 次拷贝(而且是深拷贝)操作。当然,对于仅申请少量堆空间的临时对象来说,深拷贝的执行效率依旧可以接受,但如果临时对象中的指针成员申请了大量的堆空间,那么 2 次深拷贝操作势必会影响 a 对象初始化的执行效率。

事实上,此问题一直存留在以 C++ 98/03 标准编写的 C++ 程序中。由于临时变量的产生、销毁以及发生的拷贝操作本身就是很隐晦的(编译器对这些过程做了专门的优化),且并不会影响程序的正确性,因此很少进入程序员的视野。

那么当类中包含指针类型的成员变量,使用其它对象来初始化同类对象时,怎样才能避免深拷贝导致的效率问题呢?C++11 标准引入了解决方案,该标准中引入了右值引用的语法,借助它可以实现移动语义。

C++移动构造函数(移动语义的具体实现)

所谓移动语义,指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。

以前面程序中的 demo 类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用 get_demo() 函数返回的临时对象初始化 a 时,我们只需要将临时对象的 num 指针直接浅拷贝给 a.num,然后修改该临时对象中 num 指针的指向(通常另其指向 NULL),这样就完成了 a.num 的初始化。

事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

例如,下面程序对 demo 类进行了修改:
#include <iostream>
using namespace std;
class demo{
public:
    demo():num(new int(0)){
        cout<<"construct!"<<endl;
    }

    demo(const demo &d):num(new int(*d.num)){
        cout<<"copy construct!"<<endl;
    }
    //添加移动构造函数
    demo(demo &&d):num(d.num){
        d.num = NULL;
        cout<<"move construct!"<<endl;
    }
    ~demo(){
        cout<<"class destruct!"<<endl;
    }
private:
    int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    return 0;
}
可以看到,在之前 demo 类的基础上,我们又手动为其添加了一个构造函数。和其它构造函数不同,此构造函数使用右值引用形式的参数,又称为移动构造函数。并且在此构造函数中,num 指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了 d.num,有效避免了“同一块对空间被释放多次”情况的发生。

在 Linux 系统中使用g++ demo.cpp -o demo.exe -std=c++0x -fno-elide-constructors命令执行此程序,输出结果为:

construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!

通过执行结果我们不难得知,当为 demo 类添加移动构造函数之后,使用临时对象初始化 a 对象过程中产生的 2 次拷贝操作,都转由移动构造函数完成。

我们知道,非 const 右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值、lambda 表达式等)既无名称也无法获取其存储地址,所以属于右值。当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。

在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。


读者可能会问,如果使用左值初始化同类对象,但也想调用移动构造函数完成,有没有办法可以实现呢?

默认情况下,左值初始化同类对象只能通过拷贝构造函数完成,如果想调用移动构造函数,则必须使用右值进行初始化。C++11 标准中为了满足用户使用左值初始化同类对象时也通过移动构造函数完成的需求,新引入了 std::move() 函数,它可以将左值强制转换成对应的右值,由此便可以使用移动构造函数。

有关 std::move() 函数的用法,后续章节会做详细讲解。


推荐文章
MATLAB的函数类型(5种)

本节介绍MATLAB的5种函数类型,分别为:主函数、子函数、嵌套函数、私有函数和重载函数。 主函数 通常M文件中的第一个函数为主函数,主函数后可以是任意数量的子函数。主函数可以被该文件之外的其他函数

前端面试时面试官想要听到什么答案(关于一些Vue的问题)

本文会列举一些平时面试时问到的问题和答案,并说明面试官在当时问到这个问题时所期望对方的回答:vue生命周期(钩子函数)问题请说一下vue的生命周期函数(钩子函数)。问题描述首先关于生命周期函数,一般我

Linux at命令详解:定时执行任务

要想使用at命令,读者需提前安装好at软件包,并开启atd服务。因此,首先来看看如何安装at软件包。 在Linux系统中,查看at软件包是否已安装,可以使用rpm-q命令,如下所示: [root@

JS实现简单的jQuery框架(非常详细)

本节模拟jquery框架设计一个Web应用模型,训练JavaScript面向对象的编程技能。 定义类型 在JavaScript中,可以把构造函数理解为一个类型,这个类型是JavaScript面向对象

hmi是什么?

HMI是“HumanMachineInterface”的缩写,即“人机接口”,也叫人机界面。人机界面(又称用户界面或使用者界面)是系统和用户之间进行交互和信息交换的媒介,它实现信息的内部形式与人类可以

Python partial偏函数及用法

简单的理解偏函数,它是对原始函数的二次封装,是将现有函数的部分参数预先绑定为指定值,从而得到一个新的函数,该函数就称为偏函数。相比原函数,偏函数具有较少的可变参数,从而降低了函数调用的难度。 定义偏

Linux LAMP环境搭建的前期准备

我们知道,如果我们安装的是应用服务软件,这个软件是给外网用户访问和使用的,那么应该使用源码包安装,因为源码包安装的软件经过了本机编译,性能更好。所以我们的LAMP环境搭建,在生产服务器上一般采用源码包

ai合并图层的快捷键是什么?

ai合并图层的快捷键是:Ctrl+Shift+E。注:使用该快捷键可以合并所有可见图层,隐藏状态下的图层不会被合并。ai合并图层的具体操作:1、按下【Ctrl+Shift+E】组合键即可合并所有可见图

Linux chown命令:修改文件和目录的所有者和所属组

chown命令,可以认为是"changeowner"的缩写,主要用于修改文件(或目录)的所有者,除此之外,这个命令也可以修改文件(或目录)的所属组。 当只需要修改所有者时,可使用如下chown命令的

TensorFlow WaveNet声音合成详解

WaveNet是生成原始音频波形的深层生成模型。这项突破性的技术已经被GoogleDeepMind 引入(https://deepmind.com/blog/generate-mode-raw-aud

python中&是什么意思?

&是位运算符-与,类似的还有|(或),!(非);参与运算的两个值,如果两个相应位都为1,则该位的结果为1,否则为0示例:(a&b)输出结果12,二进制解释:00001100整数在计算机中用二进制的位来

Struts2 struts-default.xml:位于struts2-core-2.1.8.1.jar文件的org.apache.struts2包中。 struts-plugin.xml:位于

Java注解(Annotation)简介

从Java5版本之后可以在源代码中嵌入一些补充信息,这种补充信息称为注解(Annotation),是Java平台中非常重要的一部分。注解都是@符号开头的,例如我们在学习方法重写时使用过的@Overri

:first-child、:last-child、:nth-child(n)、:only-child; :first-of-type、:last-of-type、:nth-of-type(n)、

理解CORS(跨域资源共享)

知识要点浏览器强制执行同源策略,拒绝不同站点的网站访问。同源策略不会阻止对其他源的请求,但是会禁用对JS响应的访问。CORS标头允许访问跨域响应。CORS与Credentials一起时需要谨慎。COR

Smconf(分布式配置管理框架)概述

Smconf专注于分布式环境下的配置的统一管理。采用Java+Zookeeper+Mongodb+SpringBoot开发。目前只支持Java,其他的使用语言需要通过调用RESTAPI来实现。 每个

谈谈Web标准设计中的常用字体和选用原则

浏览器中展示网页文字内容时,文字字体都会按照设计师在css中定义的字体族的顺序来进行显示。什么是字体族?字体族就是你在css代码中看到“font-family”的代码内的一类字体名称,例如下面这行代码

JS原型(prototype)和原型链完全攻略

在JavaScript中,每一个函数都有原型,函数被实例化以后,实例对象通过prototype属性可以访问原型,实现继承机制。 定义原型 原型实际上就是一个普通对象,继承于Object类,由Java

Vi和Vim的区别和联系

我们知道,Vi编辑器是Unix系统最初的编辑器。它使用控制台图形模式来模拟文本编辑窗口,允许查看文件中的行、在文件中移动、插入、编辑和替换文本。 尽管Vi可能是世界上复杂的编辑器(讨厌它的人是这么认

最短作业优先(SJF)调度算法(详解版)

最短作业优先(SJF)调度算法将每个进程与其下次CPU执行的长度关联起来。当CPU变为空闲时,它会被赋给具有最短CPU执行的进程。如果两个进程具有同样长度的CPU执行,那么可以由FCFS来处理。 一