C++ 11——右值引用:移动语义与完美转发
指针成员与拷贝构造
平常我们写代码都必须注意一条规则:在类中包含一个指针成员的话,那么就要特别小心拷贝构造函数的编写,因为一不小心就容易造成内存泄漏。
以下代码乍一看是没啥问题的,但是在b析构之后,a就成了悬挂指针!
#include <iostream>
using namespace std;
class HasPtr
{
public:
int *d;
HasPtr():d(new int(0)){
}
HasPtr(const HasPtr& h):d(h.d){
}
~HasPtr(){
delete d;}
};
int main()
{
HasPtr a;
HasPtr b(a);
cout<<*a.d<<endl;
cout<<*b.d<<endl;
}
以上问题非常经典,而这种拷贝构造方式也被称为浅拷贝,为了解决这个问题,只能使用深拷贝:
HasPtr(const HasPtr& h):d(new int(*h.d)){
}
移动语义
拷贝构造函数中为指针成员分配新的内存的再进行内容拷贝的做法在C++编程中几乎是视为不可违背的。不过有些有些时候,我们不需要这个:
#include <iostream>
using namespace std;
class HasPtr
{
public:
static int n_cstr; //构造函数计数
static int n_dstr; //析构函数计数
static int n_cptr; //拷贝构造函数计数
int *d;
HasPtr():d(new int(0))
{
cout<<"construct: "<<++n_cstr<<endl;
}
HasPtr(const HasPtr& h):d(new int(*h.d))
{
cout<<"copy construct: "<<++n_cptr<<endl;
}
~HasPtr()
{
cout<<"destory: "<<++n_dstr<<endl;
delete d;
}
};
int HasPtr::n_cstr = 0;
int HasPtr::n_dstr = 0;
int HasPtr::n_cptr = 0;
HasPtr GetTemp()
{
return HasPtr();
}
int main()
{
HasPtr a = GetTemp();
}
//construct :1
//copy construct:2
//destory:3
//有些编译器可能会有优化
在这里,我们可以看到:拷贝构造被调用了两次!
为什么呢?
原因是GetTemp
函数中返回对象的时候,会被构造好的对象赋值给一个临时对象,这是第一次拷贝构造;临时对象再赋给主函数中的a,这是第二次拷贝构造。
但是,这样脱裤子放屁真的好么?
假设我构造的类需要大量的内存开销,这样的方式还有存在的必要么?
C++ 11给出的答案是:false
我们可以使用新特性移动构造函数,又叫移动语义来省略掉临时对象生成的过程,换一种说法,主函数对象a直接占用临时对象开辟的内存。
#include <iostream>
using namespace std;
class HasPtr
{
public:
static int n_cstr; //构造函数计数
static int n_dstr; //析构函数计数
static int n_cptr; //拷贝构造函数计数
int *d;
HasPtr():d(new int(0))
{
cout<<"construct: "<<++n_cstr<<endl;
}
HasPtr(const HasPtr& h):d(new int(*h.d))
{
cout<<"copy construct: "<<++n_cptr<<endl;
}
//重点在这里-->
HasPtr(HasPtr&& h):d(h.d)
{
h.d = nullptr;
cout<<"right copy construct: "<<++n_cptr<<endl;
}
~HasPtr()
{
cout<<"destory: "<<++n_dstr<<endl;
delete d;
}
};
int HasPtr::n_cstr = 0;
int HasPtr::n_dstr = 0;
int HasPtr::n_cptr = 0;
HasPtr GetTemp()
{
HasPtr h;
return h;
}
int main()
{
HasPtr a = GetTemp();
}
我们可以看到,我们构造了一个带右值引用的构造函数,这个就是移动构造函数。其直接将h.d
所指向的空间赋值给this->d
。
左值、右值与右值引用
在C/C++体系中,我们时常会听到“左值”、“右值”两个称呼。那么如何去定义左值和右值呢?
一种最典型的判别方法:在赋值表达式“=”号左边的就是左值,在其右边的就是右值。
a = b+c;
//a为左值,b+c为右值
另一种方法是:可以取地址、有名字的就是左值;反之,不能取地址,没有名字的就是右值。
更为细致的讲:在C++ 11中,右值由两个概念构成,一个是将亡值,一个是纯右值。
将亡值与纯右值
纯右值是C++ 98中右值的概念。比如一个非引用函数的临时变量返回值、运算表达式1+3
产生的临时变量值、不跟对象关联的字面量值,‘2’,‘c’,true
都是纯右值。
将亡值则是C++ 11新增的跟右值引用相关的表达式,这样的表达式通常是将要被移动的对象,比如返回右值引用&&
的函数返回值、std::move
的返回值或者T&&
的类型转换函数的返回值。而剩余的,可以表示函数、对象的值都属于左值。
在C++ 11程序中,所有的值必属于左值、将亡值、纯右值三者之一。
在C++ 11中国,右值引用就是对一个右值进行引用的类型。事实上,我们只能从右值表达式获得其引用:
T&& a = ReturnRvalue();
为了区别C++ 98中的引用类型,我们称C++ 98中的引用为左值引用。
右值引用和左值引用都属于引用类型,在声明使时,都必须立即进行初始化。
在上面的代码中,ReturnRvalue
函数返回值按一般来说,其生命周期也就结束了。但是通过右值引用的声明,该右值的生命周期得到加长,将和变量a
一致。
所以,相比下面的代码:
T b = ReturnRvalue();
上面的代码会减少一次对象的构造和析构。
常量左值引用
不过值得指出的是:通常情况下,右值引用是无法绑定到左值的。
int c;
int&& d = c; //编译失败
那么相对的:左值引用是否可以绑定到右值呢?
T& e = ReturnRvalue(); //失败
const T& f = ReturnRvalue(); //成功
出现这样的情况是因为:常量左值引用在C++ 98标准中就是个“万能”的引用类型。它可以接受非常量左值、常量左值以及右值对其初始化。不过相比于右值引用所引用的右值,常量左值所引用的右值在其“余生”中只能是只读的。
常量右值引用
为了语义的完整,C++ 11中还存在着常量右值引用:
const T&& crvalueref = ReturnRvalue();
但是,一来右值引用主要就是为了移动语义,而移动语义需要右值是可以被修改的;二来如果要引用右值且让右值不可以更改,常量左值引用就够了。所以常量右值引用毫无用处!
std::move
在C++ 11中,标准库在<utility>
中提供了一个有用的函数std::move
,其名字具有迷惑性,因为实际上std::move
不能移动任何东西,其作用是,强制一个左值成为右值。
从实现上将,std::move
基本等同于一个类型转换:
static_cast<T&&>(左值);
值得一提的是,被转化的左值,其生命期并没有随之左右值的转换而改变。
以下是一个典型误用std::move
的例子:
#include <iostream>
using namespace std;
class Moveable
{
public:
int* i;
Moveable():i(new int(3)) {
}
~Moveable(){
delete i;}
Moveable(const Moveable& m):i(new int(*m.i)) {
}
Moveable(Moveable&& m):i(m.i)
{
m.i = nullptr;
}
};
int main()
{
Moveable a;
Moveable c(move(a)); //调用移动构造函数
cout<<*a.i<<endl; //运行时错误
}
移动语义的其他问题
刚刚说到,移动语义一定是要修改临时变量的值。但是下面的代码都会使得临时变量常量化,成为一个常量右值。
Moveable(const Moveable &&);
const Moveable ReturnRvalue();
所以在实现移动语义一定要注意排除不必要的const关键字。
还有要注意的是,编译器会为程序员隐式生成一个移动构造函数。不过一旦程序员显示声明了自定义的拷贝、拷贝赋值、移动赋值、析构中的一个或多个。编译器都不会在生成这个隐式的移动构造函数。
移动构造与异常
对于移动构造函数来说,抛出异常有时是件危险的事情,因为一旦在语义还未完成的时候,一个异常抛出,会导致一些指针称为悬挂指针。
因此应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept
来保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行。
另一种就是可以使用标准库中的std::move_if_noexcept
的模板函数来替代move
函数。该函数在类的移动构造函数没有noexcept
关键字修饰的时候返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept
关键字时,返回一个优质引用,从而实现移动语义。
#include <iostream>
#include <utility>
using namespace std;
struct Maythrow
{
Maythrow(){
}
Maythrow(const Maythrow&){
cout<<"copy"<<endl;}
Maythrow(Maythrow&&){
cout<<"move"<<endl;}
};
struct Nothrow
{
Nothrow(){
}
Nothrow(const Nothrow&)noexcept{
cout<<"nothrow copy"<<endl;}
Nothrow(Nothrow&&)noexcept{
cout<<"nothrow move"<<endl;}
};
int main()
{
Maythrow m;
Nothrow n;
Maythrow mt = move_if_noexcept(m); //copy
Nothrow nt = move_if_noexcept(n); //nothrow move
}
事实上,move_if_noexcept
是以 牺牲性能而保证安全的一种做法
完美转发
所谓完美转发,是指在函数模板中,完全依照参数的类型,将参数传递给函数模板中调用的另一个函数。比如:
template <class T>
void IamForword(T t){
IrunCode(t);}
本来我们期望的是,传入的是什么,就向函数IrunCode
转发什么,传入右值,传给给IrunCode
也是右值。
这似乎是一件很简单的事情,但是并不简单。在该例子中,我们参数传递的时候,使用了最基本类型进行转发,该方***导致传递给函数IrunCode
的之前就产生了一次额外的临时对象拷贝。这种转发啊只能说是正确的,但并不是完美的。
在C++ 11中引入引用折叠的语言规则来解决完美转发。以以下代码为例:
typedef const int T;
typedef T& TR;
TR& v = 1;
虽然看起来很多,但是记以下一句话就可:引用折叠总是优先将其折叠为左值引用。
进一步,我们可以把转发函数写成如下形式:
template <typename T>
void IamForword(T&& t)
{
IrunCode(static_cast<T&&>(t)); //使用引用折叠规则
}
对于一个右值而言,当它使用右值引用表达式引用的时候,该右值引用却是一个不折不扣的左值,那么我们想在函数调用中继续传递右值,就需要使用std::move
来进行左右值的转换。而std::move
通常就是一个static_cast
。不过在C++ 11中,完美转发的函数不叫move
,而叫forward
。所以以上代码可以写成如下形式:
template <typename T>
void IamForword(T&& t)
{
IrunCode(forward(t)); //使用forward
}
但是实际上,move和forward的在实现上差别都不大。
参考文献
[1] IBM XL编译器中国开发团队.深入理解C++11.机械工业出版社.2013.06.