C++11新特性:右值引用与move语义
C++11的右值引用是一个颇为重要的新特性,解决了C++中一个广为诟病的性能问题。 右值引用特性允许我们对右值进行修改。借此可以实现move语义: 右值不需要被复制直接传递给构造函数,操作结束后空的右值析构也不会销毁内存。
C++03及之前的标准中,右值是不允许被改变的,实践中也通常使用const T&
的方式传递右值。然而这是效率低下的做法,例如:
Person get(){
Person p;
return p;
}
Person p = get();
上述获取右值并初始化p
的过程包含了Person
的3个构造过程和2个析构过程。
使用右值引用的特性我们可以避免其中不必要的内存拷贝,从右值中直接拿数据过来初始化或修改左值。
一个move
构造函数是这样声明的:
class Person{
public:
Person(Person&& rhs){...}
...
};
左值和右值
C++ 表达式的值有三种类别:左值、右值和临终值。
其中左值是指在表达式的外部保留的对象,可以将左值视为有名称的对象,所有的变量都是左值。
右值是一个不在使用它的表达式的外部保留的临时值,比如函数的返回值、字面常量。
临终值是生命周期已经结束,但内存仍未回收的值,比如函数的返回值可以声明为int&&
。
若要更好地了解左值和右值之间的区别,看下面的示例:
int a = 3; // a 是变量,所以它是一个左值
// 3 是字面常量,所以它是一个右值
int b = a; // b 是变量,也是一个左值。a 是有名称的,也是一个左值
b = (a + 1); // (a + 1) 是一个右值,它是一个背后的没有名称的值
b = getValue(); // getValue() 的返回值是一个右值,他没有名称
可以通过表达式的值是否可以取地址来判断左值还是右值。左值都是可以取地址的。
右值的特点在于它不被后续计算所需要,因为它连个名字都没有,程序中无法再次访问一个右值。
右值的重复拷贝
右值虽然是不被后续计算所需要的,但它仍然需要构造和析构。
这在C++中造成了不少的代价,下面是一个中规中矩的Person
类:
class Person{
char* name;
public:
Person(const char* p){
size_t n = strlen(p) + 1;
name = new char[n];
memcpy(name, p, n);
}
Person(const Person& p){
size_t n = strlen(p.name) + 1;
name = new char[n];
memcpy(name, p.name, n);
}
~Person(){ delete[] name; }
};
其实应该用智能指针来管理动态内存,但这里为了简明起见直接用C风格的指针。
当我们拷贝Person
对象时,会有额外的不需要的内存分配过程,例如:
Person getAlice(){
Person p("alice"); // 对象创建。调用构造函数,一次 new 操作
return p; // 返回值创建。调用拷贝构造函数,一次 new 操作
// p 析构。一次 delete 操作
}
int main(){
Person a = getAlice(); // 对象创建。调用拷贝构造函数,一次 new 操作
// 右值析构。一次 delete 操作
return 0;
} // a 析构。一次 delete 操作
看到没?三次构造函数三次析构函数。返回值优化和move语义便是用来避免这些不必要的构造过程和动态内存操作的。
返回值优化
事实上编译器会对上述代码进行返回值优化,其实这里是命名返回值优化(NRVO),可以减少两次拷贝构造。
上述代码其实只需要一次构造和一次析构。为了让代码更加清晰,我们来看去掉动态内存相关代码的Person
类:
struct Person{
Person(const char* p){
cout<<"constructor"<<endl;
}
Person(const Person& p){
cout<<"copy constructor"<<endl;
}
const Person& operator=(const Person& p){
cout<<"operator="<<endl;
return *this;
}
~Person(){
cout<<"destructor"<<endl;
}
};
Person getAlice(){
Person p("alice"); return p;
}
int main(){
cout<<"______________________"<<endl;
Person a = getAlice();
cout<<"______________________"<<endl;
a = getAlice();
cout<<"______________________"<<endl;
}
程序输出是:
______________________
constructor // 1) getAlice 里的 p 被构造
______________________
constructor // 2) getAlice 里的 p 被构造
operator= // 3) 右值赋值给左值
destructor // 4) 右值析构
______________________
destructor // 5) a 析构
可见上述代码经过RVO之后,返回值没有被拷贝:
- 对于赋初值运算,甚至连
a
的拷贝构造函数都没有执行,直接使用了getAlice
里的对象。 - 对于赋值运算符,虽然没有拷贝返回值,但
operator=
还是执行了的。
move语义
对于上述输出的 3) ,右值赋值给左值调用了赋值运算符operator=
。它的完整实现可能是这样的:
const Person& Person::operator=(const Person& rhs){
delete[] name;
size_t n = strlen(rhs.name) + 1;
name = new char[n];
memcpy(name, rhs.name, n);
return *this;
}
其实上述实现是错误的,既没有解决自赋值问题,也没有保证异常安全。关于自赋值问题可参见:Effective C++: Item 11,关于异常安全可参见:Effective C++: Item 25。
但C++11提供了右值引用,rhs
不一定要声明为const
。它是可以变的,这样我们就可以把右值的数据name
直接拿过来而不需要重新申请内存。右值引用的语法是&&
:
const Person& Person::operator=(Person&& rhs){
cout<<"move operator="<<endl;
delete[] name;
name = rhs.name;
rhs.name = nullptr;
return *this;
}
注意这里的rhs.name
一定要设为空指针,这样编译器就不会去delete
它了。同样地,我们把拷贝构造函数也声明为move拷贝构造函数:
Person(Person&& p){
cout<<"move copy constructor"<<endl;
name = p.name;
p.name = nullptr;
}
然后在来重新执行main
函数,可以得到输出:
______________________
constructor // 1) getAlice 里的 p 被构造
______________________
constructor // 2) getAlice 里的 p 被构造
move operator= // 3) 右值赋值给左值,调用move赋值运算符!
destructor // 4) 右值析构
______________________
destructor // 5) a 析构
因为拷贝构造函数已经被返回值优化掉了,所以move拷贝构造函数也不会对得到调用。
本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可,转载注明来源即可: https://harttle.land/2015/10/11/cpp11-rvalue.html。如有疏漏、谬误、侵权请通过评论或 邮件 指出。