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这样的类型实现为仅可移动的。

在标准库的头文件<type_traits>里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible,使用方法仍然是使用其成员value。比如:


cout<<is_move_constructible<UnknownType>::value;


就可以打印出UnknowType是否可以移动,这在一些情况下还是非常有用的。

而有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。看看下面这段swap模板函数代码:


template<class T>

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所示的例子。

代码清单3-23


include <iostream>

include <utility>

using namespace std;

struct Maythrow{

Maythrow(){}

Maythrow(const Maythrow&){

std::cout<<"Maythorow copy constructor."<<endl;

}

Maythrow(Maythrow&&){

std::cout<<"Maythorow move constructor."<<endl;

}

};

struct Nothrow{

Nothrow(){}

Nothrow(Nothrow&&)noexcept{

std::cout<<"Nothorow move constructor."<<endl;

}

Nothrow(const Nothrow&){

std::cout<<"Nothorow move constructor."<<endl;

}

};

int main(){

Maythrow m;

Nothrow n;

Maythrow mt=move_if_noexcept(m);//Maythorow copy constructor.

Nothrow nt=move_if_noexcept(n);//Nothorow move constructor.

return 0;

}

//编译选项:g++ -std=c++11 3-3-8.cpp


在代码清单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完成了,也不能达到最好的效果。但结论是明显的,移动语义可以解决编译器无法解决的优化问题,因而总是有用的。