一个新接触到的概念——左值、右值。
实话实说,关于左值、右值的概念也是这次整理时才第一次接触到。所以就根据看到的相关资料做一个归纳。
1. 左值 & 右值
在C++表达式的特性中有一个左值和右值的概念。如果一个表达式可以放在赋值语句的左侧,就称之为左值,如果不能放到表达式的左侧,就称之为右值。通俗的来讲,左值(lvalue)是一个表达式,它表示一个可被标识的(变量或对象的)内存位置,并且允许使用&操作符来获取这块内存的地址。如果一个表达式不是左值,那它就被定义为右值。
//赋值操作需要左操作数是一个左值。var 是一个有内存位置的对象,因此它是左值.
int var;
var = 4;
4 = var; // 错误!
(var + 1) = 4; // 错误!
在上面的例子中,常量 4 和表达式 var + 1 都不是左值(也就是说,它们是右值),因为它们都是表达式的临时结果,而没有可识别的内存位置(也就是说,只存在于计算过程中的每个临时寄存器中)。因此,赋值给它们是没有任何语义上的意义的——我们赋值到了一个不存在的位置。
不同的运算符对运算对象的要求各不相同,例如:
- 赋值运算符的左侧要求是左值,得到的结果还是左值,因此我们可以继续对它赋值;
- 取地址符作用于左值运算对象,返回对象的指针为右值,因此取地址运算表达式只能位于赋值语句的右侧。
2. 右值引用
在介绍C++11 新引入的右值引用概念之前,我们先介绍一下一般的引用类型。需要注意的是,只有左值可以付给引用,如:
int& ref = 9; //编译错误
我们只能这样做:
int l=9;
int &r=l;
cout<<l<<" "<<r<<endl; //9 9
r++;
cout<<l<<" "<<r<<endl; //10 10
没有问题,l是左值,所以可以将引用类型的r绑定到l上,今后对r的操作就等同于对l的操作。
C++11引入了右值引用的概念,使得我们把引用与右值进行绑定。使用两个“取地址符号”:
int&& rvalue_ref = 99;
下面的例子更有助于理解:
#include <iostream>
void f(int& i) { std::cout << "lvalue ref: " << i << "\n"; }
void f(int&& i) { std::cout << "rvalue ref: " << i << "\n"; }
int main()
{
int i = 77;
f(i); // lvalue ref called
f(99); // rvalue ref called
f(std::move(i));
/*std::move,这是标准库中提供的方法,它可以将左值显式转换为右值引用类型,从而告诉编译器,可以像右值(临时变量)一样处理它。同时也意味着接下来除了对i赋值或销毁以外,不再使用它。*/
return 0;
}
/*
输出:
lvalue ref: 77
rvalue ref: 99
lvalue ref: 77
*/
还存在更为隐晦的右值,如下例:
#include <iostream>
int getValue () //getValue()是一个右值
{
int ii = 10;
return ii;
}
int main()
{
const int& val = getValue(); // OK
int& val = getValue(); // NOT OK
const int&& val = getValue(); // OK
int&& val = getValue(); // OK
return 0;
}
因此,对比以下代码,我们可以发现:
void printReference (const int& value) //可以接受参数为左值,也可以接受右值。
{
cout << value;
}
void printReference (int&& value) //只能接受右值引用作为参数。
{
cout << value;
}
3. 移动语义
了解了右值引用的基本概念后,其实我也很纠结:C++11引入这样一个令人头大的特征到底有什么用?在参考文献中作者的解释其实是挺容易让我理解的:
3.1 实例演示
在C++语言中,引用是作为一种高效,安全的传递数据的方式而存在的。除了一般的引用类型,还可以声明const引用。例如,我们有以下一个Image类:
class Image
{
private:
int width = 0;
int height = 0;
char *data = nullptr;
public:
Image(int w, int h): width(w), height(h){
data = new char[getSize()];
}
int getSize(){
return width * height;
}
virtual ~Image(){
if (data != nullptr){
delete data;
data = nullptr;
width = 0;
height = 0;
}
}
}
上面只是这个类的雏形,只有构造函数,析构函数和取得数据大小的功能。接下来添加比较两个Image是否相同的函数。最简单的形式大致如下。
bool isSame(Image& img)
{
if(width == img.width
&& height == img.height){
return (memcmp(data,img.data,getSize())==0);
}
else{
return false;
}
}
这里使用引用类型的参数,避免了没有必要的拷贝动作。当然我们还可以做得更好:由于比较函数没有必要也不应该对比较对象的内容进行修改,所以还可用下面的形式进行承诺:
bool isSame(const Image& img)
{
if(width == img.width
&& height == img.height){
char* in = static_cast<char*>(img.data);
return (memcmp(data,in,getSize())==0);
}
else{
return false;
}
}
这里,通过在参数前面增加const修饰符,向isSame方法的调用者保证,不会修改img的内容。
到此为止都是我们所熟悉的内容。然而,如果我们希望继续添加将一个Image的一部分merge到另一个Image上的方法,函数的内容大致如下(这里忽略处理的细节):
void merge(Image& img){
//接管img中的数据。
img.height = 0;
img.width = 0;
img.data = nullptr;
}
类似的操作在处理在输入对象时一般有两种处理方式。有时希望只是参照而不破坏输入数据,这时可以使用前面讲到的为参数增加const修饰符的方式来承诺;有时为了提高效率或者其他的原因希望可以接管输入的数据,就像上面代码的状态。这时的行为更像是数据移动。对于第二种方式,如果仅仅定义一般的引用类型,利用者根本没有办法通过方法声明来确定这个操作是否会接管参数中的数据。这种不确定性会造成很大的麻烦。解决这个问题的方法就是利用右值引用:
void merge(Image&& img){
//接管img中的数据。
img.height = 0;
img.width = 0;
img.data = nullptr;
}
我们将参数声明为右值引用,要求像一个临时变量一样任性地使用数据。使用这个函数的方法如下:
Image img1(100, 100);
Image img2(100, 200);
img1.merge(std::move(img2));
注意代码中的std::move,这是标准库中提供的方法,它可以将左值显式转换为右值引用类型,从而告诉编译器,可以像右值(临时变量)一样处理它。同时也意味着接下来除了对img2赋值或销毁以外,不再使用它。C++11通过使用右值引用提供了一种接管数据的标准方法。
3.2 移动语义的功能
当一个函数的参数按值传递时,这就会进行拷贝。当然,编译器懂得如何去拷贝。 而对于我们自定义的类型,我们也许需要提供拷贝构造函数。但是不得不说,拷贝的代价是昂贵的。所以我们需要寻找一个避免不必要拷贝的方法,即C++11提供的移动语义。
实际上,我们在第2部分介绍的右值引用,其主要目的是用于创建移动构造函数和移动赋值运算。
移动构造函数类似于拷贝构造函数,把类的实例对象作为参数,并创建一个新的实例对象。但是,移动构造函数可以避免内存的重新分配,因为我们知道右值引用提供了一个暂时的对象,而不是进行copy,所以我们可以进行移动。换言之,在涉及到关于临时对象时,右值引用和移动语义允许我们避免不必要的拷贝。我们不想拷贝将要消失的临时对象,所以这个临时对象的资源可以被我们用作于其他的对象。右值就是典型的临时变量,并且他们可以被修改。如果我们知道一个函数的参数是一个右值,我们可以把它当做一个临时存储。这就意味着我们要移动而不是拷贝右值参数的内容。这就会节省很多的空间。
更详细的解释可参考:文献