类别:类作者
对C++程序员来说,编写C++程序有一条必须注意的规则,就是在类中包含了一个指针成员的话,那么就要特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄露。我们来看看代码清单3-16中的例子。
在代码清单3-16中,我们定义了一个HasPtrMem的类。这个类包含一个指针成员,该成员在构造时接受一个new操作分配堆内存返回的指针,而在析构的时候则会被delete操作用于释放之前分配的堆内存。在main函数中,我们声明了HasPtrMem类型的变量a,又使用a初始化了变量b。按照C++的语法,这会调用HasPtrMem的拷贝构造函数。这里的拷贝构造函数由编译器隐式生成,其作用是执行类似于memcpy的按位拷贝。这样的构造方式有一个问题,就是a.d和b.d都指向了同一块堆内存。因此在main作用域结束的时候,a和b的析构函数纷纷被调用,当其中之一完成析构之后(比如b),那么a.d就成了一个“悬挂指针”(dangling pointer),因为其不再指向有效的内存了。那么在该悬挂指针上释放内存就会造成严重的错误。
这个问题在C++编程中非常经典。这样的拷贝构造方式,在C++中也常被称为“浅拷贝”(shollow copy)。而在未声明构造函数的情况下,C++也会为类生成一个浅拷贝的构造函数。通常最佳的解决方案是用户自定义拷贝构造函数来实现“深拷贝”(deep copy),我们来看看代码清单3-17中的修正方法。
在代码清单3-17中,我们为HasPtrMem添加了一个拷贝构造函数。拷贝构造函数从堆中分配新内存,将该分配来的内存的指针交还给d,又使用(h.d)对d进行了初始化。通过这样的方法,就避免了悬挂指针的困扰。
拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎被视为是不可违背的。不过在一些时候,我们确实不需要这样的拷贝构造语义。我们可以看看代码清单3-18所示的例子。
在代码清单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所示。
最让人感到不安就是拷贝构造函数的调用。在我们的例子里,类HasPtrMem只有一个int类型的指针。而如果HasPtrMem的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。可以想象,这种情况一旦发生,a的初始化表达式的执行速度将相当堪忧。而更为令人堪忧的是,临时变量的产生和销毁以及拷贝的发生对于程序员来说基本上是透明的,不会影响程序的正确性,因而即使该问题导致程序的性能不如预期,也不易被程序员察觉(事实上,编译器常常对函数返回值有专门的优化,我们在本节结束时会提到)。
让我们把目光再次聚集在临时对象上,即图3-1中的main函数的部分。按照C++的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而a在拷贝构造的时候,又会被分配堆内存。这样的一去一来似乎并没有太大的意义,那么我们是否可以在临时对象构造a的时候不分配内存,即不使用所谓的拷贝构造语义呢?
在C++11中,答案是肯定的。我们可以看看如图3-2所示的示意图。
图3-2中的上半部分可以看到从临时变量中拷贝构造变量a的做法,即在拷贝时分配新的堆内存,并从临时对象的堆内存中拷贝内容至a.d。而构造完成后,临时对象将析构,因此其拥有的堆内存资源会被析构函数释放。而图3-2的下半部分则是一种“新”方法(实际跟我们在代码清单3-1中做得差不多),该方法在构造时使得a.d指向临时对象的堆内存资源。同时我们保证临时对象不释放所指向的堆内存(下面解释怎么做),那么在构造完成后,临时对象被析构,a就从中“偷”到了临时对象所拥有的堆内存资源。
在C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”。而这样的“偷”的行为,则称之为“移动语义”(move semantics)。当然,换成白话的中文,可以理解为“移为己用”。我们可以看看代码清单3-19中是如何来实现这种移动语义的。
相比于代码清单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语言中,我们常常会提起左值(lvalue)、右值(rvalue)这样的称呼。而在编译程序时,编译器有时也会在报出的错误信息中会包含左值、右值的说法。不过左值、右值通常不是通过一个严谨的定义而为人所知的,大多数时候左右值的定义与其判别方法是一体的。一个最为典型的判别方法就是,在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的,则称为“右值”。比如:
a = b + c;
在这个赋值表达式中,a就是一个左值,而b + c则是一个右值。这种识别左值、右值的方法在C++中依然有效。不过C++中还有一个被广泛认同的说法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。那么这个加法赋值表达式中,&a是允许的操作,但&(b + c)这样的操作则不会通过编译。因此a是一个左值,(b + c)是一个右值。
这些判别方法通常都非常有效。更为细致地,在C++11中,右值是由两个概念构成的,一个是将亡值(xvalue,eXpiring Value),另一个则是纯右值(prvalue,Pure Rvalue)。
其中纯右值就是C++98标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值(我们在前面多次提到了)就是一个纯右值。一些运算表达式,比如1 + 3产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如:2、‘c’、true,也是纯右值。此外,类型转换函数的返回值、lambda表达式(见7.3节)等,也都是右值。
而将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std::move的返回值(稍后解释),或者转换为T&&的类型转换函数的返回值(稍后解释)。而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。
在C++11中,右值引用就是对一个右值进行引用的类型。事实上,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。通常情况下,我们只能是从右值表达式获得其引用。比如:
T && a = ReturnRvalue();
这个表达式中,假设ReturnRvalue返回一个右值,我们就声明了一个名为a的右值引用,其值等于ReturnRvalue函数返回的临时变量的值。
为了区别于C++98中的引用类型,我们称C++98中的引用为“左值引用”(lvalue reference)。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。
在上面的例子中,ReturnRvalue函数返回的右值在表达式语句结束后,其生命也就终结了(通常我们也称其具有表达式生命期),而通过右值引用的声明,该右值又“重获新生”,其生命期将与右值引用类型变量a的生命期一样。只要a还“活着”,该右值临时量将会一直“存活”下去。
所以相比于以下语句的声明方式:
T b = ReturnRvalue();
我们刚才的右值引用变量声明,就会少一次对象的析构及一次对象的构造。因为a是右值引用,直接绑定了ReturnRvalue()返回的临时量,而b只是由临时值构造而成的,而临时量在表达式结束后会析构因应就会多一次析构和构造的开销。
不过值得指出的是,能够声明右值引用a的前提是ReturnRvalue返回的是一个右值。通常情况下,右值引用是不能够绑定到任何的左值的。比如下面的表达式就是无法通过编译的。
int c;
int && d = c;
相对地,在C++98标准中就已经出现的左值引用是否可以绑定到右值(由右值进行初始化)呢?比如:
T & e = ReturnRvalue();
const T & f = ReturnRvalue();
这样的语句是否能够通过编译呢?这里的答案是:e的初始化会导致编译时错误,而f则不会。
出现这样的状况的原因是,在常量左值引用在C++98标准中开始就是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其初始化的时候,常量左值引用还可以像右值引用一样将右值的生命期延长。不过相比于右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
既然常量左值引用在C++98中就已经出现,读者可能会努力地搜索记忆,想找出在C++中使用常量左值绑定右值的情况。不过可能一切并不如愿。这是因为,在C++11之前,左值、右值对于程序员来说,一直呈透明状态。不知道什么是左值、右值,并不影响写出正确的C++代码。引用的是左值和右值通常也并不重要。不过事实上,在C++98通过左值引用来绑定一个右值的情况并不少见,比如:
const bool & judgement = true;
就是一个使用常量左值引用来绑定右值的例子。不过与如下声明相比较看起来似乎差别不大。
const bool judgement = true;
可能很多程序员都没有注意到其中的差别(从语法上讲,前者直接使用了右值并为其“续命”,而后者的右值在表达式结束后就销毁了)。
事实上,即使在C++98中,我们也常可以使用常量左值引用来减少临时对象的开销,如代码清单3-20所示。
在代码清单3-20中,我们声明了结构体Copyable,该结构体的唯一的作用就是在被拷贝构造的时候打印一句话:Copied。而两个函数,AcceptVal使用了值传递参数,而AcceptRef使用了引用传递。在以ReturnRvalue返回的右值为参数的时候,AcceptRef就可以直接使用产生的临时值(并延长其生命期),而AcceptVal则不能直接使用临时对象。
编译运行代码清单3-20,可以得到以下结果:
Pass by value:
Copied
Copied
Pass by reference:
Copied
可以看到,由于使用了左值引用,临时对象被直接作为函数的参数,而不需要从中拷贝一次。读者可以自行分析一下输出结果,这里就不赘述了。而在C++11中,同样地,如果在代码清单3-20中以右值引用为参数声明如下函数:
void AcceptRvalueRef(Copyable && ) {}
也同样可以减少临时变量拷贝的开销。进一步地,还可以在AcceptRvalueRef中修改该临时值(这个时候临时值由于被右值引用参数所引用,已经获得了函数时间的生命期)。不过修改一个临时值的意义通常不大,除非像3.3.2节一样使用移动语义。
就本例而言,如果我们这样实现函数:
void AcceptRvalueRef(Copyable && s) {
Copyable news = std::move(s);
}
这里std::move的作用是强制一个左值成为右值(看起来很奇怪?这个我们会在下面一节中解释)。该函数就是使用右值来初始化Copyable变量news。当然,如同我们在上小节提到的,使用移动语义的前提是Copyable还需要添加一个以右值引用为参数的移动构造函数,比如:
``
Copyable(Copyable &&o) { / 实现移动语义 / }
这样一来,如果Copyable类的临时对象(即ReturnRvalue返回的临时值)中包含一些大块内存的指针,news就可以如同代码清单3-19一样将临时值中的内存“窃”为己用,从而从这个以右值引用参数的AcceptRvalueRef函数中获得最大的收益。事实上,右值引用的来由从来就跟移动语义紧紧相关。这是右值存在的一个最大的价值(另外一个价值是用于转发,我们会在后面的小节中看到)。
对于本例而言,很有趣的是,读者也可以思考一下:如果我们不声明移动构造函数,而只声明一个常量左值的构造函数会发生什么?如同我们刚才提到的,常量左值引用是个“万能”的引用类型,无论左值还是右值,常量还是非常量,一概能够绑定。那么如果Copyable没有移动构造函数,下列语句:
Copyable news = std::move(s);
将调用以常量左值引用为参数的拷贝构造函数。这是一种非常安全的设计—移动不成,至少还可以执行拷贝。因此,通常情况下,程序员会为声明了移动构造函数的类声明一个常量左值为参数的拷贝构造函数,以保证在移动构造不成时,可以使用拷贝构造(不过,我们也会在之后看到一些特殊用途的反例)。
为了语义的完整,C++11中还存在着常量右值引用,比如我们通过以下代码声明一个常量右值引用。
const T && crvalueref = ReturnRvalue();
但是,一来右值引用主要就是为了移动语义,而移动语义需要右值是可以被修改的,那么常量右值引用在移动语义中就没有用武之处;二来如果要引用右值且让右值不可以更改,常量左值引用往往就足够了。因此在现在的情况下,我们还没有看到常量右值引用有何用处。
表3-1中,我们列出了在C++11中各种引用类型可以引用的值的类型。值得注意的是,只要能够绑定右值的引用类型,都能够延长右值的生命期。
![image](https://yqfile.alicdn.com/ce89b9dc0426704ddd86a2e507f4fc80c326d127.png)
有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用(这在模板中比较常见)。标准库在头文件中提供了3个模板类:is_rvalue_reference、is_lvalue_reference、is_reference,可供我们进行判断。比如:
cout << is_rvalue_reference::value;
我们通过模板类的成员value就可以打印出stirng &&是否是一个右值引用了。配合第4章中的类型推导操作符decltype,我们甚至还可以对变量的类型进行判断。当读者搞不清楚引用类型的时候,不妨使用这样的小工具实验一下。
###3.3.4 std::move:强制转化为右值
在C++11中,标准库在中提供了一个有用的函数std::move,这个函数的名字具有迷惑性,因为实际上std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:
static_cast(lvalue);
值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。如果读者期望std::move转化的左值变量lvalue能立即被析构,那么肯定会失望了。我们来看代码清单3-21所示的例子。
![image](https://yqfile.alicdn.com/4197db9055e06677b04529a9eed50c7e93d0a9de.png)
在代码清单3-21中,我们为类型Moveable定义了移动构造函数。这个函数定义本身没有什么问题,但调用的时候,使用了Moveable c(move(a));这样的语句。这里的a本来是一个左值变量,通过std::move将其转换为右值。这样一来,a.i就被c的移动构造函数设置为指针空值。由于a的生命期实际要到main函数结束才结束,那么随后对表达式*a.i进行计算的时候,就会发生严重的运行时错误。
这是个典型误用std::move的例子。当然,标准库提供该函数的目的不是为了让程序员搬起石头砸自己的脚。事实上,要使用该函数,必须是程序员清楚需要转换的时候。比如上例中,程序员应该知道被转化为右值的a不可以再使用。不过更多地,我们需要转换成为右值引用的还是一个确实生命期即将结束的对象。我们来看看代码清单3-22所示的正确例子。
![image](https://yqfile.alicdn.com/3ac6057f18649550d0c2e8686eb7a7981c404ec9.png)
![image](https://yqfile.alicdn.com/c2edaa64ac47793b7fedfbc99117461ac7512062.png)
在代码清单3-22中,我们定义了两个类型:HugeMem和Moveable,其中Moveable包含了一个HugeMem的对象。在Moveable的移动构造函数中,我们就看到了std::move函数的使用。该函数将m.h强制转化为右值,以迫使Moveable中的h能够实现移动构造。这里可以使用std::move,是因为m.h是m的成员,既然m将在表达式结束后被析构,其成员也自然会被析构,因此不存在代码清单3-21中的生存期不对的问题。另外一个问题可能是std::move使用的必要性。这里如果不使用std::move(m.h)这样的表达式,而是直接使用m.h这个表达式将会怎样?
其实这是C++11中有趣的地方:可以接受右值的右值引用本身却是个左值。这里的m.h引用了一个确定的对象,而且m.h也有名字,可以使用&m.h取到地址,因此是个不折不扣的左值。不过这个左值确确实实会很快“灰飞烟灭”,因为拷贝构造函数在Moveable对象a的构造完成后也就结束了。那么这里使用std::move强制其为右值就不会有问题了。而且,如果我们不这么做,由于m.h是个左值,就会导致调用HugeMem的拷贝构造函数来构造Moveable的成员h(虽然这里没有声明,读者可以自行添加实验一下)。如果是这样,移动语义就没有能够成功地向类的成员传递。换言之,还是会由于拷贝而导致一定的性能上的损失。
事实上,为了保证移动语义的传递,程序员在编写移动构造函数的时候,应该总是记得使用std::move转换拥有形如堆内存、文件句柄等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义。而即使成员没有移动构造函数,那么接受常量左值的构造函数版本也会轻松地实现拷贝构造,因此也不会引起大的问题。
###3.3.5 移动语义的一些其他问题
我们在前面多次提到,移动语义一定是要修改临时变量的值。那么,如果这样声明移动构造函数:
Moveable(const Moveable &&)
或者这样声明函数:
const Moveable ReturnVal();
都会使得的临时变量常量化,成为一个常量右值,那么临时变量的引用也就无法修改,从而导致无法实现移动语义。因此程序员在实现移动语义一定要注意排除不必要的const关键字。
在C++11中,拷贝/移动构造函数实际上有以下3个版本:
T Object(T &)
T Object(const T &)
T Object(T &&)
其中常量左值引用的版本是一个拷贝构造版本,而右值引用版本是一个移动构造版本。默认情况下,编译器会为程序员隐式地生成一个(隐式表示如果不被使用则不生成)移动构造函数。不过如果程序员声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或者多个,编译器都不会再为程序员生成默认版本。默认的移动构造函数实际上跟默认的拷贝构造函数一样,只能做一些按位拷贝的工作。这对实现移动语义来说是不够的。通常情况下,如果需要移动语义,程序员必须自定义移动构造函数。当然,对一些简单的、不包含任何资源的类型来说,实现移动语义与否都无关紧要,因为对这样的类型而言,移动就是拷贝,拷贝就是移动。
同样地,声明了移动构造函数、移动赋值函数、拷贝赋值函数和析构函数中的一个或者多个,编译器也不会再为程序员生成默认的拷贝构造函数。所以在C++11中,拷贝构造/赋值和移动构造/赋值函数必须同时提供,或者同时不提供,程序员才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类都仅能实现一种语义。
其实,只实现一种语义在类的编写中也是非常常见的。比如说只有拷贝语义的类型—事实上在C++11之前我们见过大多数的类型的构造都是只使用拷贝语义的。而只有移动语义的类型则非常有趣,因为只有移动语义表明该类型的变量所拥有的资源只能被移动,而不能被拷贝。那么这样的资源必须是唯一的。因此,只有移动语义构造的类型往往都是“资源型”的类型,比如说智能指针,文件流等,都可以视为“资源型”的类型。在本书的第5章中,就可以看到标准库中的仅可移动的模板类:unique_ptr。一些编译器,如vs2011,现在也把ifstream这样的类型实现为仅可移动的。
在标准库的头文件里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible,使用方法仍然是使用其成员value。比如:
cout << is_move_constructible::value;
就可以打印出UnknowType是否可以移动,这在一些情况下还是非常有用的。
而有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看看下面这段swap模板函数代码:
template
void swap(T& a, T& b)
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
如果T是可以移动的,那么移动构造和移动赋值将会被用于这个置换。代码中,a先将自己的资源交给tmp,随后b再将资源交给a,tmp随后又将从a中得到的资源交给b,从而完成了一个置换动作。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。而如果T不可移动却是可拷贝的,那么拷贝语义会被用来进行置换。这就跟普通的置换语句是相同的了。因此在移动语义的支持下,我们仅仅通过一个通用的模板,就可能更高效地完成置换,这对于泛型编程来说,无疑是具有积极意义的。
另外一个关于移动构造的话题是异常。对于移动构造函数来说,抛出异常有时是件危险的事情。因为可能移动语义还没完成,一个异常却抛出来了,这就会导致一些指针就成为悬挂指针。因此程序员应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态。而标准库中,我们还可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。我们来看一下代码清单3-23所示的例子。
![image](https://yqfile.alicdn.com/73a9f13e57cfa0550ce3bf44b60aceac9dd09a64.png)
在代码清单3-23中,可以清楚地看到move_if_noexcept的效果。事实上,move_if_noexcept是以牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用noexcept进行描述,否则就会损失更多的性能。这是库的开发者和使用者必须协同平衡考虑的。
还有一个与移动语义看似无关,但偏偏有些关联的话题是,编译器中被称为RVO/NRVO的优化(RVO, Return Value Optimization,返回值优化,或者NRVO,Named Return Value optimization)。事实上,在本节中大量的代码都使用了-fno-elide-constructors选项在g++/clang++中关闭这个优化,这样可以使读者在代码中较为容易地利用函数返回的临时量右值。
但若在编译的时候不使用该选项的话,读者会发现很多构造和移动都被省略了。对于下面这样的代码,一旦打开g++/clang++的RVO/NRVO,从ReturnValue函数中a变量拷贝/移动构造临时变量,以及从临时变量拷贝/移动构造b的二重奏就通通没有了。
A ReturnRvalue() { A a(); return a; }
A b = ReturnRvalue();
b变量实际就使用了ReturnRvalue函数中a的地址,任何的拷贝和移动都没有了。通俗地说,就是b变量直接“霸占”了a变量。这是编译器中一个效果非常好的一个优化。不过RVO/NRVO并不是对任何情况都有效。比如有些情况下,一些构造是无法省略的。还有一些情况,即使RVO/NRVO完成了,也不能达到最好的效果。但结论是明显的,移动语义可以解决编译器无法解决的优化问题,因而总是有用的。
###3.3.6 完美转发
所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。比如:
template
void IamForwording(T t) { IrunCodeActually(t); }
这个简单的例子中,IamForwording是一个转发函数模板。而函数IrunCodeActually则是真正执行代码的目标函数。对于目标函数IrunCodeActually而言,它总是希望转发函数将参数按照传入Iamforwarding时的类型传递(即传入IamForwording的是左值对象,IrunCodeActually就能获得左值对象,传入IamForwording的是右值对象,IrunCodeActually就能获得右值对象),而不产生额外的开销,就好像转发者不存在一样。
这似乎是一件非常容易的事情,但实际却并不简单。在上面例子中,我在IamForwording的参数中使用了最基本类型进行转发,该方法会导致参数在传给IrunCodeActually之前就产生了一次额外的临时对象拷贝。因此这样的转发只能说是正确的转发,但谈不上完美。
所以通常程序员需要的是一个引用类型,引用类型不会有拷贝的开销。其次,则需要考虑转发函数对类型的接受能力。因为目标函数可能需要能够既接受左值引用,又接受右值引用。那么如果转发函数只能接受其中的一部分,我们也无法做到完美转发。结合表3-1,我们会想到“万能”的常量左值类型。不过以常量左值为参数的转发函数却会遇到一些尴尬,比如:
void IrunCodeActually(int t){}
template
void IamForwording(const T & t) { IrunCodeActually(t); }
这里,由于目标函数的参数类型是非常量左值引用类型,因此无法接受常量左值引用作为参数,这样一来,虽然转发函数的接受能力很高,但在目标函数的接受上却出了问题。那么我们可能就需要通过一些常量和非常量的重载来解决目标函数的接受问题。这在函数参数比较多的情况下,就会造成代码的冗余。而且依据表3-1,如果我们的目标函数的参数是个右值引用的话,同样无法接受任何左值类型作为参数,间接地,也就导致无法使用移动语义。
那C++11是如何解决完美转发的问题的呢?实际上,C++11是通过引入一条所谓“引用折叠”(reference collapsing)的新语言规则,并结合新的模板推导规则来完成完美转发。
在C++11以前,形如下列语句:
typedef const int T;
typedef T& TR;
TR& v = 1; // 该声明在C++98中会导致编译错误
其中TR& v = 1这样的表达式会被编译器认为是不合法的表达式,而在C++11中,一旦出现了这样的表达式,就会发生引用折叠,即将复杂的未知表达式折叠为已知的简单表达式,具体如表3-2所示。
![image](https://yqfile.alicdn.com/b558e772fef37fd231fcaddd000d488f5ff9bdcb.png)
这个规则并不难记忆,因为一旦定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用。而模板对类型的推导规则就比较简单,当转发函数的实参是类型X的一个左值引用,那么模板参数被推导为X&类型,而转发函数的实参是类型X的一个右值引用的话,那么模板的参数被推导为X&&类型。结合以上的引用折叠规则,就能确定出参数的实际类型。进一步,我们可以把转发函数写成如下形式:
template
void IamForwording(T && t) {
IrunCodeActually(static_cast(t));
}
![image](https://yqfile.alicdn.com/5290bf02a0ce5ba7be1313798f4eb868b0640a84.png)
注意一下,我们不仅在参数部分使用了T &&这样的标识,在目标函数传参的强制类型转换中也使用了这样的形式。比如我们调用转发函数时传入了一个X类型的左值引用,可以想象,转发函数将被实例化为如下形式:
void IamForwording(X& && t) {
IrunCodeActually(static_cast(t));
}
应用上引用折叠规则,就是:
void IamForwording(X& t) {
IrunCodeActually(static_cast(t));
}
这样一来,我们的左值传递就毫无问题了。实际使用的时候,IrunCodeActually如果接受左值引用的话,就可以直接调用转发函数。不过读者可能发现,这里调用前的static_cast没有什么作用。事实上,这里的static_cast是留给传递右值用的。
而如果我们调用转发函数时传入了一个X类型的右值引用的话,我们的转发函数将被实例化为:
void IamForwording(X&& && t) {
IrunCodeActually(static_cast(t));
}
应用上引用折叠规则,就是:
void IamForwording(X&& t) {
IrunCodeActually(static_cast(t));
}
这里我们就看到了static_cast的重要性。如我们在上面几个小节中讲到的,对于一个右值而言,当它使用右值引用表达式引用的时候,该右值引用却是个不折不扣的左值,那么我们想在函数调用中继续传递右值,就需要使用std::move来进行左右值的转换。而std::move通常就是一个static_cast。不过在C++11中,用于完美转发的函数却不再叫作move,而是另外一个名字:forward。所以我们可以把转发函数写成这样:
template
void IamForwording(T && t) {
IrunCodeActually(forward(t));
}
move和forward在实际实现上差别并不大。不过标准库这么设计,也许是为了让每个名字对应于不同的用途,以应对未来可能的扩展(虽然现在我们使用move可能也能通过完美转发函数的编译,但这并不是推荐的做法)。
我们来看一个完美转发的例子,如代码清单3-24所示。
![image](https://yqfile.alicdn.com/f20d299f44db08de5d511eb8fec85a47c48cb6be.png)
在代码清单3-24中,我们使用了表3-1中的所有4种类型的值对完美转发进行测试,可以看到,所有的转发都被正确地送到了目的地。
完美转发的一个作用就是做包装函数,这是一个很方便的功能。我们对代码清单3-24中的转发函数稍作修改,就可以用很少的代码记录单参数函数的参数传递状况,如代码清单3-25所示。
![image](https://yqfile.alicdn.com/d650f295733d7956528f509c9591d9ec9d757795.png)
![image](https://yqfile.alicdn.com/c6bbe576e4b5829cf7c62faa3ae1c8b23ff6f471.png)