3.3.2 移动语义

拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎被视为是不可违背的。不过在一些时候,我们确实不需要这样的拷贝构造语义。我们可以看看代码清单3-18所示的例子。

代码清单3-18


include <iostream>

using namespace std;

class HasPtrMem{

public:

HasPtrMem():d(new int(0)){

cout<<"Construct:"<<++n_cstr<<endl;

}

HasPtrMem(const HasPtrMem&h):d(new int(*h.d)){

cout<<"Copy construct:"<<++n_cptr<<endl;

}

~HasPtrMem(){

cout<<"Destruct:"<<++n_dstr<<endl;

}

int*d;

static int n_cstr;

static int n_dstr;

static int n_cptr;

};

int HasPtrMem::n_cstr=0;

int HasPtrMem::n_dstr=0;

int HasPtrMem::n_cptr=0;

HasPtrMem GetTemp(){return HasPtrMem();}

int main(){

HasPtrMem a=GetTemp();

}

//编译选项:g++3-3-3.cpp-fno-elide-constructors


在代码清单3-18中,我们声明了一个返回一个HasPtrMem变量的函数。为了记录构造函数、拷贝构造函数,以及析构函数调用的次数,我们使用了一些静态变量。在main函数中,我们简单地声明了一个HasPtrMem的变量a,要求它使用GetTemp的返回值进行初始化。编译运行该程序,我们可以看到下面的输出:


Construct:1

Copy construct:1

Destruct:1

Copy construct:2

Destruct:2

Destruct:3


这里构造函数被调用了一次,这是在GetTemp函数中HasPtrMem()表达式显式地调用了构造函数而打印出来的。而拷贝构造函数则被调用了两次。这两次一次是从GetTemp函数中HasPtrMem()生成的变量上拷贝构造出一个临时值,以用作GetTemp的返回值,而另外一次则是由临时值构造出main中变量a调用的。对应地,析构函数也就调用了3次。这个过程如图3-1所示。

3.3.2 移动语义 - 图1

图 3-1 函数返回时的临时变量与拷贝

最让人感到不安就是拷贝构造函数的调用。在我们的例子里,类HasPtrMem只有一个int类型的指针。而如果HasPtrMem的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。可以想象,这种情况一旦发生,a的初始化表达式的执行速度将相当堪忧。而更为令人堪忧的是,临时变量的产生和销毁以及拷贝的发生对于程序员来说基本上是透明的,不会影响程序的正确性,因而即使该问题导致程序的性能不如预期,也不易被程序员察觉(事实上,编译器常常对函数返回值有专门的优化,我们在本节结束时会提到)。

让我们把目光再次聚集在临时对象上,即图3-1中的main函数的部分。按照C++的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而a在拷贝构造的时候,又会被分配堆内存。这样的一去一来似乎并没有太大的意义,那么我们是否可以在临时对象构造a的时候不分配内存,即不使用所谓的拷贝构造语义呢?

在C++11中,答案是肯定的。我们可以看看如图3-2所示的示意图。

3.3.2 移动语义 - 图2

图 3-2 拷贝构造与移动构造

图3-2中的上半部分可以看到从临时变量中拷贝构造变量a的做法,即在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容至a.d。而构造完成后,临时对象将析构,因此其拥有的堆内存资源会被析构函数释放。而图3-2的下半部分则是一种“新”方法(实际跟我们在代码清单3-1中做得差不多),该方法在构造时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存(下面解释怎么做),那么在构造完成后,临时对象被析构,a就从中“偷”到了临时对象所拥有的堆内存资源。

在C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”。而这样的“偷”的行为,则称之为“移动语义”(move semantics)。当然,换成白话的中文,可以理解为“移为己用”。我们可以看看代码清单3-19中是如何来实现这种移动语义的。

代码清单3-19


include <iostream>

using namespace std;

class HasPtrMem{

public:

HasPtrMem():d(new int(3)){

cout<<"Construct:"<<++n_cstr<<endl;

}

HasPtrMem(const HasPtrMem&h):d(new int(*h.d)){

cout<<"Copy construct:"<<++n_cptr<<endl;

}

HasPtrMem(HasPtrMem&&h):d(h.d){//移动构造函数

h.d=nullptr;//将临时值的指针成员置空

cout<<"Move construct:"<<++n_mvtr<<endl;

}

~HasPtrMem(){

delete d;

cout<<"Destruct:"<<++n_dstr<<endl;

}

int*d;

static int n_cstr;

static int n_dstr;

static int n_cptr;

static int n_mvtr;

};

int HasPtrMem::n_cstr=0;

int HasPtrMem::n_dstr=0;

int HasPtrMem::n_cptr=0;

int HasPtrMem::n_mvtr=0;

HasPtrMem GetTemp(){

HasPtrMem h;

cout<<"Resource from"<<func<<":"<<hex<<h.d<<endl;

return h;

}

int main(){

HasPtrMem a=GetTemp();

cout<<"Resource from"<<func<<":"<<hex<<a.d<<endl;

}

//编译选项:g++ -std=c++11 3-3-4.cpp-fno-elide-constructors


相比于代码清单3-18,代码清单3-19中的HasPtrMem类多了一个构造函数HasPtrMem(HasPtrMem&&),这个就是我们所谓的移动构造函数。与拷贝构造函数不同的是,移动构造函数接受一个所谓的“右值引用”的参数,关于右值我们接下来会解释,读者可以暂时理解为临时变量的引用。可以看到,移动构造函数使用了参数h的成员d初始化了本对象的成员d(而不是像拷贝构造函数一样需要分配内存,然后将内容依次拷贝到新分配的内存中),而h的成员d随后被置为指针空值nullptr(请参见7.1节,这里等同于NULL)。这就完成了移动构造的全过程。

这里所谓的“偷”堆内存,就是指将本对象d指向h.d所指的内存这一条语句,相应地,我们还将h的成员d置为指针空值。这其实也是我们“偷”内存时必须做的。这是因为在移动构造完成之后,临时对象会立即被析构。如果不改变h.d(临时对象的指针成员)的话,则临时对象会析构掉本是我们“偷”来的堆内存。这样一来,本对象中的d指针也就成了一个悬挂指针,如果我们对指针进行解引用,就会发生严重的运行时错误。

为了看看移动构造的效果,我们让GetTemp和main函数分别打印变量h和变量a中的指针h.d和a.d,在我们的实验机上运行的结果如下:


Construct:1

Resource from GetTemp:0x603010

Move construct:1

Destruct:1

Move construct:2

Destruct:2

Resource from main:0x603010

Destruct:3


可以看到,这里没有调用拷贝构造函数,而是调用了两次移动构造函数,移动构造的结果是,GetTemp中的h的指针成员h.d和main函数中的a的指针成员a.d的值是相同的,即h.d和a.d都指向了相同的堆地址内存。该堆内存在函数返回的过程中,成功地逃避了被析构的“厄运”,取而代之地,成为了赋值表达式中的变量a的资源。如果堆内存不是一个int长度的数据,而是以MByte为单位的堆空间,那么这样的移动带来的性能提升将非常惊人。

或许读者会质疑说:为什么要这么费力地添加移动构造函数呢?完全可以选择改变GetTemp的接口,比如直接传一个引用或者指针到GetTemp的参数中去,效果应该也不差。其实从性能上来讲,这样的做法确实毫无问题,甚至只好不差。不过从使用的方便性上来讲效果不好。如果函数返回临时值的话,可以在单条语句里完成很多计算,比如我们可以很自然地写出如下语句:


Caculate(GetTemp(),SomeOther(Maybe(),Useful(Values,2)));


但如果通过传引用或者指针的方法而不返回值的话,通常就需要很多语句来完成上面的工作。可能是像下面这样的代码:


string*a;vector b;//事先声明一些变量用于传递返回值

Useful(Values,2,a);//最后一个参数是指针,用于返回结果

SomeOther(Maybe(),a,b);//最后一个参数是引用,用于返回结果

Caculate(GetTemp(),b);


两者在代码编写效率和可读性上都存在着明显的差别。而即使声明这些传递返回值的变量为全局的,函数再将这些引用和指针都作为返回值返回给调用者,我们也需要在Caculate调用之前声明好所有的引用和指针。这无疑是繁琐的工作。函数返回临时变量的好处就是不需要声明变量,也不需要知道生命期。程序员只需要按照最自然的方式,使用最简单的语句就可以完成大量的工作。

那么再回到移动语义上来,还有一个最为关键的问题没有解决,那就是移动构造函数何时会被触发。之前我们只是提到了临时对象,一旦我们用到的是个临时变量,那么移动构造语义就可以得到执行。那么,在C++中如何判断产生了临时对象?如何将其用于移动构造函数?是否只有临时变量可以用于移动构造?……读者可能还有很多问题。要回答这些问题,需要先了解一下C++中的“值”是如何分类的。

注意 事实上,移动语义并不是什么新的概念,在C++98/03的语言和库中,它已经存在了,比如:

❑在某些情况下拷贝构造函数的省略(copy constructor elision in some contexts)

❑智能指针的拷贝(auto_ptr“copy”)

❑链表拼接(list::splice)

❑容器内的置换(swap on containers)

以上这些操作都包含了从一个对象向另外一个对象的资源转移(至少概念上)的过程,唯一欠缺的是统一的语法和语义的支持,来使我们可以使用通用的代码移动任意的对象(就像我们今天可以使用通用的代码来拷贝任意对象一样)。如果能够任意地使用对象的移动而不是拷贝,那么标准库中的很多地方的性能都会大大提高。