一直以来,我都对C++中左值(lvalue)和右值(lvalue)的概念模糊不清。我认为是时候好好理解他们了,因为这些概念随着C++语言的进化变得越来越重要。
首先,让我们避开那些正式的定义。在C++中,一个左值是指向一个指定内存的东西。另一方面,右值就是不指向任何地方的东西。通常来说,右值是暂时和短命的,而左值则活的很久,因为他们以变量的形式(variable)存在。我们可以将左值看作为容器(container)而将右值看做容器中的事物。如果容器消失了,容器中的事物也就自然就无法存在了。
让我们现在来看一些例子:
int x = 666; //ok
在这里,666
是一个右值。一个数字(从技术角度来说他是一个字面常量(literal constant))没有指定的内存地址,当然在程序运行时一些临时的寄存器除外。在该例中,666
被赋值(assign)给x
,x
是一个变量。一个变量有着具体(specific)的内存位置,所以他是一个左值。C++中声明一个赋值(assignment)需要一个左值作为它的左操作数(left operand):这完全合法。
对于左值x
,你可以做像这样的操作:
int* y = &x; //ok
在这里我通过取地址操作符&
获取了x
的内存地址并且把它放进了y
。&
操作符需要一个左值并且产生了一个右值,这也是另一个完全合法的操作:在赋值操作符的左边我们有一个左值(一个变量),在右边我们使用取地址操作符产生的右值。
然而,我们不能这样写:
int y;
666 = y; //error!
可能上面的结论是显而易见的,但是从技术上来说是因为666
是一个字面常量也就是一个右值,它没有一个具体的内存位置(memory location),所以我们会把y
分配到一个不存在的地方。
下面是GCC给出的变异错误提示:
error: lvalue required as left operand of assignment
赋值的左操作数需要一个左值,这里我们使用了一个右值666
。
我们也不能这样做:
int* y = &666;// error~
GCC给出了以下错误提示:
error: lvalue required as unary '&' operand`
&
操作符需要一个左值作为操作数,因为只有一个左值才拥有地址。
我们知道一个赋值的左操作数必须是一个左值,因此下面的这个函数肯定会抛出错误:lvalue required as left operand of assignment
int setValue()
{
return 6;
}
// ... somewhere in main() ...
setValue() = 3; // error!
错误原因很清楚:setValue()
返回了一个右值(一个临时值6
),他不能作为一个赋值的左操作数。现在,我们看看如果函数返回一个左值,这样的赋值会发生什么变化。看下面的代码片段(snippet):
int global = 100;
int& setGlobal()
{
return global;
}
// ... somewhere in main() ...
setGlobal() = 400; // OK
该程序可以运行,因为在这里setGlobal()
返回一个引用(reference),跟之前的setValue()
不同。一个引用是指向一个已经存在的内存位置(global
变量)的东西,因此它是一个左值,所以它能被赋值。注意这里的&
:它不是取地址操作符,他定义了返回的类型(一个引用)。
可以从函数返回左值看上去有些隐晦,它在你做一些进阶的编程例如实现一些操作符的重载(implementing overload operators)时会很有作用,这些知识会在未来的章节中讲述。
一个左值可以被转换(convert)为右值,这完全合法且经常发生。让我们先用+
操作符作为一个例子,根据C++的规范(specification),它使用两个右值作为参数并返回一个右值(译者按:可以将操作符理解为一个函数)。
让我们看下面的代码片段:
int x = 1;
int y = 3;
int z = x + y; // ok
等一下,x
和y
是左值,但是加法操作符需要右值作为参数:发生了什么?答案很简单:x
和y
经历了一个隐式(implicit)的左值到右值(lvalue-to-rvalue)的转换。许多其他的操作符也有同样的转换——减法、加法、除法等等。
相反呢?一个右值可以被转化为左值吗?不可以,它不是技术所限,而是C++编程语言就是那样设计的。
在C++中,当你做这样的事:
int y = 10;
int& yref = y;
yref++; // y is now 11
这里将yref
声明为类型int&
:一个对y
的引用,它被称作左值引用(lvalue reference)。现在你可以开心地通过该引用改变y
的值了。
我们知道,一个引用必须只想一个具体的内存位置中的一个已经存在的对象,即一个左值。这里y
确实存在,所以代码运行完美。
现在,如果我缩短整个过程,尝试将10
直接赋值给我的引用,并且没有任何对象持有该引用,将会发生什么?
int& yref = 10; // will it work?
在右边我们有一个临时值,一个需要被存储在一个左值中的右值。在左边我们有一个引用(一个左值),他应该指向一个已经存在的对象。但是10
是一个数字常量(numeric constant),也就是一个左值,将它赋给引用与引用所表述的精神冲突。
如果你仔细想想,那就是被禁止的从右值到左值的转换。一个volitile
的数字常量(右值)如果想要被引用,需要先变成一个左值。如果那被允许,你就可以通过它的引用来改变数字常量的值。相当没有意义,不是吗?更重要的是,一旦这些值不再存在这些引用该指向哪里呢?
下面的代码片段同样会发生错误,原因跟刚才的一样:
void fnc(int& x)
{
}
int main()
{
fnc(10); // Nope!
// This works instead:
// int x = 10;
// fnc(x);
}
我将一个临时值10
传入了一个需要引用作为参数的函数中,产生了将右值转换为左值的错误。这里有一个解决方法(workaround),创造一个临时的变量来存储右值,然后将变量传入函数中(就像注释中写的那样)。将一个数字传入一个函数确实不太方便。
先看看GCC对于之前两个代码片段给出的错误提示:
error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'
GCC认为引用不是const
的,即一个常量。根据C++规范,你可以将一个const
的左值绑定到一个右值上,所以下面的代码可以成功运行:
const int& ref = 10; // OK!
当然,下面的也是:
void fnc(const int& x)
{
}
int main()
{
fnc(10); // OK!
}
背后的道理是相当直接的,字面常量10
是volatile
的并且会很快失效(expire),所以给他一个引用是没什么意义的。如果我们让引用本身变成常量引用,那样的话该引用指向的值就不能被改变了。现在右值被修改的问题被很好地解决了。同样,这不是一个技术限制,而是C ++人员为避免愚蠢麻烦所作的选择。
应用:C++中经常通过常量引用来将值传入函数中,这避免了不必要的临时对象的创建和拷贝。
编译器会为你创建一个隐藏的变量(即一个左值)来存储初始的字面常量,然后将隐藏的变量绑定到你的引用上去。那跟我之前的一组代码片段中手动完成的是一码事,例如:
// the following...
const int& ref = 10;
// ... would translate to:
int __internal_unique_name = 10;
const int& ref = __internal_unique_name;
现在你的引用指向了真实存在的事物(知道它走出作用域外)并且你可以正常使用它,除了不能改变他指向的值。
const int& ref = 10;
std::cout << ref << "\n"; // OK!
std::cout << ++ref << "\n"; // error: increment of read-only reference ‘ref’
右值引用及其相关的move语义是C++11新引入的最强大的特性之一。前文说到,左值(非const)可以被修改(赋值),但右值不能。但C++11引入的右值引用特性,打破了这个限制,允许我们获取右值的引用,并修改之。让我们先看点代码:
定义一个类Intvec及其赋值操作符重载函数如下:
class Intvec
{
public:
...
Intvec& operator=(const Intvec& other)
{
log("copy assignment operator");
Intvec tmp(other); //构造一个临时对象,因为other为const,不能被修改
std::swap(m_size, tmp.m_size);
std::swap(m_data, tmp.m_data);
//跟临时对象交换值,临时对象晰构时会delete [] m_data
return *this;
}
private:
size_t m_size;
int* m_data; //存放int数组,构造时动态分配
};
代码要点:
代码使用了copy-swap策略,即先分配资源再更改自身状态,这样可以保证当资源分配失败的时候,自身能够维持原先状态,《高效C++》有条规则描述这个主题。所以先根据other拷贝构造一个临时对象tmp, 然后与tmp进行swap,m_data交换给了tmp之后,也会随着tmp的晰构而被释放。
之所以把other声明为const,有两个理由,其一是赋值操作不应该更改other,其二是可以传入一个右值。其实这样的声明随处可见。
假设现有类型为Intvec的对象v,用一个新对象给它赋值:
v = Intvec(33);
这句代码合法,它构造一个临时对象,为右值,传入到Intvec的赋值运算符重载函数中。这个代码是可以工作,而且通常情况下都比较高效。但是如果Intvec里包含某些m_handle成员,创建和释放m_handle比较昂贵,那么拷贝构造越少越好。这种情况,我们设想一下,如果v能跟Intvec(33)临时对象直接进行内部数据交换,而不需要在重载函数里使用Intvec tmp(other);
构造一个新对象出来swap,那该有多好!
如你所料,C++11引入的“右值引用”和“move语义”就可以实现这个目标,新的语法很简单,我们重载一个新的赋值操作运算符函数:
Intvec& operator=(Intvec&& other)
{
log("move assignment operator");
std::swap(m_size, other.m_size);
std::swap(m_data, other.m_data);
return *this;
}
对于v = Intvec(33);
这种写法就会调用此版本的重载函数(即传入一个右值)。
&&语法声明右值引用,表示一个指向右值的引用,通过这个引用,可以修改右值。
参考链接:https://www.jianshu.com/p/94b0221f64a5