本篇文章主要探讨的是右值和右值引用。但是会以和左值对比的方式探讨。
一、左值和右值
很多初学者对左值和右值的理解是从字面上的---表达式等号左侧的值为左值,等号右侧的值为右值。
但这是一种片面不准确的说法。
在C++中左值一般指 一个指向特定内存的具有名称的值,这个值具有名称和相对稳定的内存地址,生命周期相对较长。相对的,右值不具有名称,且不指向稳定的内存地址,生命周期相对短暂,通常是暂时性的。
基于上诉特征,我们可以用取址符判定左值和右值,左值可用取址符取址,而对右值取址编译器会报错。
下面是一些左值和右值的例子
int a = 6; //a是左值 6是右值
int get_value()
{
return a;
}
void set_value(int v)
{
a = v;
}
int main()
{
int b = get_value(); //b是左值 但是get_value()是右值,&get_value()无法编译成功
int c = a + b; //a,b,c都是左值 但是a+b计算结果是右值
set_value(2); //常量2是右值,但是set_value的形参v是左值
c++; //c虽然是左值,但是c++是右值,因为后置++操作会先生成一个c的临时复制,再对c进行++操作。
//如果&c++则是对c的临时复制的取址,无法编译通过
++c; //++c是左值,前置++不会生成c的临时拷贝
//&++c则是c先自增,然后对c取址
}
我们需要仔细理解几个点:
函数的返回值是不具名的,生命周期很短的值,所以它是右值;
形参都是左值,因为其是具名的,在其函数体内具有十分稳定的内存空间和生命周期;
二、左值引用和右值引用
左值引用
在C++开发中,我们经常使用左值引用。
int a = 1;
int &b = a; //b为左值引用,可以引用非常量左值
上述的b为非常量引用,只能引用非常量左值,常量左值和右值都不可以被其引用。
const int ca = 1;
int &b = ca; //error,ca为常量左值,不能被非常量引用b引用
int &b = 1; //error,1为右值,不能被非常量引用b引用
但是,常量引用,既可以引用左值也可以引用右值;即可以引用常量也可以引用非常量。
int a = 1;
const int ca = 1;
const int &cb = a; //ok,常量左值cb引用非常量左值a
const int &cb = ca; //ok,常量左值cb引用常量左值ca
const int &cb = 1; //ok,常量左值cb引用右值1。
在第5行,const int &cb = 1 这里不是赋值,和const int cb = 1不一样。前者是引用,右值1的生命周期被延长;后者是赋值,结束后右值1将被释放。
2、右值引用
虽然上面介绍的常量左值引用好像适用性很广,能够满足我们的各种引用需要。但是常量左值有一个很明显的弊端,就是其常量性。被其引用的值将无法修改。见下列代码
struct A{
int val = 5;
};
A getA(){
return A();
}
int main(){
const A &ca = getA(); //getA()的返回值为右值,只能用被常量引用ca引用
ca.val = 666; //error, 由于ca为常量引用,此句会编译失败
}
所以,我们有时需要既能引用右值,又能修改被引用的对象的办法——右值引用。
同样是上面的例子,如果使用右值引用,即可达到目的
A &&ra = getA(); //ok,ra为右值引用,引用getA的返回值(右值)
ra.val = 666; //ok
定义右值引用需要在类型后添加 &&,可以和左值引用(在类型后添加&)一起记忆。
右值引用是专为引用右值而来,用其引用右值就像左值引用引用左值一样方便和正确。
三、针对右值的性能优化-移动语义
经过第一节对左值和右值的介绍。我们可以认识到在我们日常的C++编码中,需要经常处理的各种类型常量(除字符串常量外)、函数返回值,一些不具名的表达式(例如a+b)等都是右值,但是由于右值作为一种临时的对象,具有较短的生命周期,往往在使用后就被销毁,并且还伴随这一些对象的构造。这会引发一个性能问题,对于一个对性能有着要求的项目而言,我们需要尽量减少不必要的对象销毁和建立。
例如如下代码
class A {
public:
A() :data_(new char[size_]) {
std::cout << "A被构造" << std::endl;
}
~A() {}
A(const A& cp) :data_(new char[size_]) {
memcpy(data_, cp.data_, size_); //进行深拷贝
std::cout << "A的拷贝构造被调用" << std::endl;
}
private:
char *data_;
int size_ = 4096;
};
A getA() { //2:返回值进行一次拷贝构造
return A(); //1:A()为右值
}
int main() {
A a(getA()); // 3:getA()为右值,又进行了一次拷贝构造
system("pause");
}
上述代码,将会输出如下结果(注意,需要加上编译参数-fno-elide-constructors关闭编译优化):
A被构造
A的拷贝构造被调用
A的拷贝构造被调用
在这个过程中,将会有3次对data_的开辟,然而我们知道这是没有必要的。
通过上面对右值引用的介绍,我们知道,如果我们使用右值引用,而不使用拷贝构造(参数为常量左值引用),我们就能将上诉代码中1处A()这个右值的生命周期延长,一直使用,而不产生不必要的拷贝和开辟data_的操作。所以我们更改下上诉代码,增加一个构造函数
A(A&& mv)
{
data_ = mv.data_;
mv.data_ = nullptr;
std::cout << "移动构造被调用" << std::endl;
}
加入此构造函数之后,输出结果将变成
A被构造
移动构造被调用
移动构造被调用
因为后两次构造传入的参数都是右值,编译器会优先使用最新加入的这个构造函数。而调用此构造函数,将不会再次开辟data_的空间,达到了我们的目的。而这种构造函数,我们称为移动构造函数,它的形参为右值引用类型。
使用移动构造,我们能对我们的项目进行合理有效的优化。事实上,我们的编译器已经对我们的代码进行了一些优化。
【参考&致谢】
《现代C++语言核心特性解析》---谢丙堃