对象移动
很多情况下都会发生对象拷贝。在其中某些情况下,对象拷贝后就立即销毁了。在这些情况下,如果对象较大或者对象本身要求分配空间(如string),进行不必要的拷贝代价非常高。移动而非拷贝对象会大大提升性能。
就像你的同学有一本漫画书,你也想看这本漫画,你有两种方法,第一种是找他复印一份,第二种是直接借他的看。很显然第一种花销很大并且浪费时间,第二种就很方便。对应的第一种就是拷贝,第二种就是移动。
1.右值引用
为了支持移动操作,C++11引入了一个新的引用类型——右值引用。
右值引用就是必须要绑定到右值的引用,通过**&&**而不是&来获得右值引用。
①左值和右值
C++中所有的值必属于左值、右值两者之一:
-
左值:可以取地址的、有名字的就是左值。如:变量表达式a。
-
右值:不能取地址的、没有名字的就是右值。右值又分为纯右值和将亡值:
- 纯右值:纯右值包括临时量(计算表达式结果创建的无名对象。包括非引用返回的函数的函数返回值、算数运算符的结果、关系运算符的结果、位运算符的结果等)、字面值常量。
如:返回类型非引用的函数返回值是一个临时量、42是一个int行字面值常量、"abc"是一个const char*型字面值常量。
- 将亡值:跟右值引用相关的表达式,通常是将要被移除的对象移为他用。
如:返回右值引用T&&的函数返回值、std::move的返回值、转换为T&&的类型转换函数的返回值。
②左值引用和右值引用
左值引用就是常规引用,类似左值引用,右值引用也是某个对象的另一个名字而已。左值引用和右值引用都必须初始化。
- 左值引用:只能绑定到一个左值上,但常量引用是个万能的引用类型,可以绑定到左值也可以绑定到右值上。
- 右值引用:不能绑定到任何左值,只能绑定到一个右值上,如果像绑定到一个左值,需要通过std::move()将左值强制转换成右值。
如:
int i = 42;
int &r = i; //正确:左值引用绑定到一个左值上
int &&rr = i; //错误:右值引用不能绑定到左值上
int &r2 = i * 42; //错误:算数表达式的结果是一个右值
const int &r3 = i * 42; //正确:常量引用可以绑定到左值也可以绑定到右值
int &&rr2 = i * 42; //正确:右值引用绑定到一个右值上
③变量表达式都是左值
变量可以看作一个只有运算对象而没有运算符的表达式,变量表达式都是左值。
右值引用类型的变量也是一个左值,我们不能将有一个右值引用绑定到右值引用对象上。 如:
int &&rr1 = 42;
int &&rr2 = rr1; //错误:rr1是一个左值
④标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。通过move函数可以获得绑定到左值上地右值引用。如:
int &&rr3 = move(rr1); //rr1是一个左值,通过move把左值显式转换成一个右值
move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
如果是一些基本类型比如int和char[10]定长数组等类型,使用move的话仍然会发生拷贝(因为没有对应的移动构造函数)。所以,move对于含资源(堆内存或句柄)的对象来说更有意义。
⑤通过右值引用,右值的生命周期与右值引诱类型变量的生命周期一样长
例如:
class A {
public:
A():_p(new int(0)) {
cout << "construct" << endl;
}
A(const A &rhs):_p(new int(*rhs._p)) {
cout << "copy construct" << endl;
}
~A() {
delete _p;
cout << "destruct" << endl;
}
private:
int *_p;
};
A getA() {
return A();
}
int main() {
A a = getA(); //getA()得到一个临时量
}
//如果调用的是拷贝构造函数,则输出结果为:
construct
copy construct
destruct
copy construct
destruct
destruct
从上面的例子中可以看到,在没有返回值优化的情况下,拷贝构造函数调用了两次,一次是GetA()函数内部创建的对象返回出来构造一个临时对象产生的,另一次是在main函数中构造a对象产生的。
//如果加上移动赋值运算符,其他代码同上
int main() {
//由于通过右值引用绑定了临时量,延长了临时量的生命周期,因此比之前少了一次拷贝构造和析构
A&& a = GetA();
return 0;
}
//输出结果为:
construct
copy construct
destruct
destruct
2.移动构造函数和移动赋值运算符
①移动构造函数
类似拷贝构造函数,移动构造函数的第一个参数也是该类类型的一个引用,但与拷贝构造函数不同的是,移动构造函数的引用是右值引用。
一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
移动构造函数不分配新的内存,它接管给定的源对象的内存。移后源对象将继续存在,最终,移后源对象会被销毁,意味着将在其上执行析构函数。
从一个对象移动数据并不会销毁此对象。我们在编写一个移动操作时,必须确保移后源对象进入一个可析构的状态,还必须保证对象仍然可以安全的为其赋予新值或者可以安全的使用而不依赖于当前值。
②通过移动构造函数解决临时量的深拷贝问题
一个带有堆内存的类,必须提供一个深拷贝的构造函数,因为默认构造函数浅拷贝会造成指针悬挂问题。
提供深拷贝构造函数虽然可以保证正确,但是有时候会造成额外的性能损耗,因为有时候这种拷贝是不必要的,如函数返回值产生的临时量需要额外的拷贝,如果堆内存很大的话,这个拷贝构造造成的代价很大。通过移动构造函数可以避免这种不必要的拷贝构造。
如:继续上述例子
class A {
public:
A():_p(new int(0)) {
cout << "construct" << endl;
}
A(const A &rhs):_p(new int(*rhs._p)) {
cout << "copy construct" << endl;
}
~A() {
delete _p;
cout << "destruct" << endl;
}
private:
int *_p;
};
A getA() {
return A();
}
int main() {
A a = getA(); //getA()得到一个临时量
}
//如果调用的是拷贝构造函数,则输出结果为:
construct
copy construct
destruct
copy construct
destruct
destruct
可以看出调用getA函数时产生了临时量,进行了一次不必要的拷贝构造函数。下面我们加入一个移动构造函数:
//加入移动构造函数,其余不变
A(A &&rhs):_p(rhs._p) {
rhs._p = nullptr; //保证移后源对象处于一个有效状态
cout << "move construct" << endl;
}
//结果为:
construct
move construct
move construct
destruct
destruct
destruct
调用了两次移动构造而不是拷贝构造,节省了两次拷贝产生的内存浪费,提高了效率。
③移动赋值运算符
类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值。
④合成的移动操作
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为他合成移动构造函数或移动赋值运算符。
⑤移动右值拷贝左值
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。如果实参是左值调用拷贝构造函数,如果实参是右值调用移动构造函数。
而如果一个类没有移动构造函数,则右值是通过拷贝构造函数常量引用来移动的。
3.普通引用、常量引用和右值引用
①普通引用、常量引用和常量引用都是对象的别名,区别在于:
- 普通引用:对象必须是左值,有自己的名字。
- 常量引用:对象可以是左值也可以是右值。
- 右值引用:对象是右值是个临时量没有自己的名字,右值引用就是给它加个名字让他变成普通的变量。
常量引用和右值引用实现的效果相似**,都可以避免多余的拷贝**,节省内存,区别在于:
-
普通引用:只能接受左值,可以修改引用的值。
-
常量引用:可以接受左值也可以接受右值,但是不能修改引用的值。
-
右值引用:只能接受右值,可以修改引用的值。
4.右值引用和成员函数
其他成员函数也能拥有拷贝和移动版本
如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。
其中一个版本是指向const的左值引用(常量引用)来接受左值实参,另一个版本是接受非const的右值引用来接受右值实参。