最近在看C++程序设计语言(特别版),在读到6.2.1节时候遇到一个困惑,进而补充了些关于左值、右值、自增自减相关的知识。当时遇到的问题如下:
void f(int x , int y) { int j = x= y; // x=y的值是赋值后的x的值 int* p = &++x; // p指向x int* q = & (x++); // 错误:x++不是一个左值(它不是存储在x里的值) int* pp = & (x>y?x : y); // 较大的那个int的地址 }
当时就对“int* p = &++x; ”和“int* q = & (x++);”这个两段代码比较困惑,为什么一个对而另一个不对呢。根据代码后的注释,那什么又是左值呢什么是右值呢?要解决这个困惑。就要从根部入手,其实这个代码包括了好几个知识点比如指针初始化、自增自减、取地址等。
首先让我们从自增说起。自增用“++”来实现操作。自增有两种定义,一种为自增运算符在语句块之后,则先执行语句块,再执行自增操作;另一种恰恰相反,自增运算符在语句块之前,则先执行自增,再执行语句块。顺便提下自减原理与其相同。下面我将从自增的汇编代码中展现两种定义的不同之处。
C++源码如下:
int nVarOne = argc; int nVarTwo = argc; nVarTwo = 5 + (nVarOne++); nVarTwo = 5 + (++nVarOne);对应C++源码的汇编代码如下:
// C++源码对比,后缀自增运算 nVarTwo = 5 + (nVarOne++); ; 取出变量nVarOne,保持在edx中 0040BA34 mov edx, dword ptr [ebp-4] ; 对edx执行加等于5 0040BA37 add edx, 5 ; 将edx赋值给变量nVarTwo,可以看到没有对变量nVarOne执行自增运算 0040BA3A mov dword ptr [ebp-8], edx ; 再次取出变量nVarOne数据存入eax中 0040BA3D mov eax, dword ptr [ebp-4] ; 执行eax加等于1 0040BA40 add eax, 1 ; 将eax赋值给变量nVarOne,等同于对变量nVarOne执行自增1操作 0040BA43 mov dword ptr [ebp-4], eax // C++源码对比,前缀自增运算 nVarTwo = 5 + (++nVarOne); ; 取出变量nVarOne数据放入ecx中 0040BA46 mov ecx,dword ptr [ebp-4] ; 对ecx执行加等于1操作 0040BA49 add ecx, 1 ; 将ecx赋值给变量nVarOne,完成自增1操作 0040BA4C mov dword ptr [ebp-4], ecx ; 取出变量nVarOne放入edx中 0040BA4F mov edx, dword ptr [ebp-4] ; 对edx执行加等于5 0040BA52 add edx,5 ; 将结果edx赋值给变量nVarTwo 0040BA55 mov dword ptr [ebp-8], edx
通过汇编代码再结合我们前头说的“int* p = &++x; ”和i“nt* q = & (x++);”这两段前缀自增和后缀自增代码,我们就可以对其进行分析啦。首先对于“&++x”由于前增“++”优先级要高于地址“&”优先级,所以“&++x”的意思是“&(++x)”,而“++x”通过汇编代码看其实就等同于“x=x+1”在内存中的x值自增1,然后我们就可以对这个内存中的x值取地址啦。接下来我们分析& (x++)为什么说它错误呢,原因是这样的根据自增后缀定义“& (x++)”要先执行语句块也就是说要先执行“&x”操作然后才执行自增操作,而“&”操作不是想当然的“&x”,对于编译器来说是要把高级语言转换成汇编执行的,再回头看看上面的汇编代码你就应该知道了编译器需要先把x的值从内存中取出保存到寄存器中(如同上文的“取出变量nVarOne,保持在edx中”),而“&”操作是无法对寄存器实现的,也就如代码注释写到的“它不是存储在x里的值”,所以“int* q = & (x++);”错误。
但“int* q = & (x++);”代码的注释中还提到了“x++不是一个左值”,那什么又是左值,什么又是右值呢?让我们接着看。
首先给出具体定义:
在C++程序设计语言(特别版)指出,一个对象就是存储中一片连续的区域,左值(lvalue)就是引用某个对象的表达式,右值(rvalue)是指表达式的值。你可能看到这个定义就理解为左值就是某个可以放到赋值左边的东西,然而并不是每个左值都能被用在赋值的左边。比如这个例子:
const int i;
对于常量i是不可以将其放入赋值号的左边的,但其确实是个左值——不能改变的左值。而没有被声明为常量的左值常常被称做可修改的左值。正确理解左值并不是指能放在赋值号左边的值,右值自然也不是指能放到赋值号右边的。左值可以放在赋值号右边,但右值一定只能是在赋值号的右边。但左值也不一定能放在赋值号的左边(没有迷糊吧!)那又如何去区分左右值呢?咱们接着看。
左值和右值都是针对表达式而言的,左值是指表达式结束后依然存在的持久对象,右值是指表达式结束时就不再存在的临时对象。一个区分左值与右值的便捷方法是:看能不能对表达式取地址,如果能,则为左值,否则为右值。下面给出一些例子来进行说明。
int a = 10; int b = 20; int* pFlag = &a; vector<int> vctTemp; vctTemp.push_back(1); string str1 = "hello "; string str2 = "world"; const int &m = 1;
你可以试着分析下a,b, a+b, a++, ++a, pFlag, *pFlag, vctTemp[0], 100, string("hello"), str1, str1+str2, m分别是左值还是右值?
你可能会注意到表格中最后说”M是一个常引用“。那接下来就谈谈常量引用和非常量引用的问题。C++程序设计(特别版)在第5.5节提到,对”普通“ T&的初始式必须是一个类型T的左值。对于一个const T&的初始化不必是一个左值,甚至可以不是类型T的。在这种情况下:
1) 首先,如果需要将应用到T的隐式类型转换。
2) 而后将结果存入一个类型T的临时变量。
3) 最后,将临时变量用做初始式的值。
看下这个例子:
int main() { int a = 2; double &r = a; return 0; }gcc error: invalid initialization of reference of type 'double&' from expression of type 'int'
int main() { double a = 2; double &r = a; return 0; }
int main() { int a = 2; const double &r = a; return 0; }
这也是正确的。因为它符合前头提到的这句话”对于一个const T&的初始化不必是一个左值,甚至可以不是类型T的“。对这个代码的const T&的初始化解释是:
const double tmp = (double)a //这里就产生了一个临时变量 const double &r = tmp; //而后用这个临时变量作为r的初始式
临时的中间变量都是const,所有没有const的引用会失败。
现在又面临了一个新问题,什么是右值引用,什么是左值引用,什么是非常量左值引用,什么是常量左值引用,什么是非常量右值引用,什么是常量右值引用?(以下文字摘抄于网络非原创)
右值引用、左值引用:
右值引用是一种复合类型,跟C++的传统引用很类似。为更准确地区分两种类型,我们把传统的C++引用称为左值引用。而使用“引用”这一术语时,我们的意思同时包含两种引用:左值引用和右值引用。左值引用通过在类型之后加一个“&”来定义;右值引用则在某个类型之后添加两个“&”。右值引用的行为跟左值引用类似,不同之处在于:右值引用可以绑定到临时量(右值),而(非const的)左值引用却不能绑定到右值(具体详情请参看百度百科)。
非常量左值引用:
只能绑定到非常量左值,不能绑定到常量左值、非常量右值和常量右值。如果允许绑定到常量左值和常量右值,则非常量左值引用可以用于修改常量左值和常量右值,这明显违反了其常量的含义如果允许绑定到非常量右值,则会导致非常危险的情况出现,因为非常量右值是一个临时对象,非常量左值引用可能会使用一个已经被销毁了的临时对象。
常量左值引用:
常量左值引用可以绑定到所有类型的值,包括非常量左值、常量左值、非常量右值和常量右值。
可以看出,使用左值引用时,我们无法区分出绑定的是否是非常量右值的情况。那么,为什么要对非常量右值进行区分呢,区分出来了又有什么好处呢?这就牵涉到C++中一个著名的性能问题——拷贝临时对象。考虑下面的代码:
vector<int> GetAllScores() { vector<int> vctTemp; vctTemp.push_back(90); vctTemp.push_back(95); returnvctTemp; }当使用vector<int> vctScore = GetAllScores()进行初始化时,实际上调用了三次构造函数。尽管有些编译器可以采用RVO(Return Value Optimization)来进行优化,但优化工作只在某些特定条件下才能进行。可以看到,上面很普通的一个函数调用,由于存在临时对象的拷贝,导致了额外的两次拷贝构造函数和析构函数的开销。当然,我们也可以修改函数的形式为void GetAllScores(vector<int> &vctScore),但这并不一定就是我们需要的形式。另外,考虑下面字符串的连接操作:
strings1("hello"); strings = s1 + "a"+ "b"+ "c"+ "d"+ "e";在对s进行初始化时,会产生大量的临时对象,并涉及到大量字符串的拷贝操作,这显然会影响程序的效率和性能。怎么解决这个问题呢?如果我们能确定某个值是一个非常量右值(或者是一个以后不会再使用的左值),则我们在进行临时对象的拷贝时,可以不用拷贝实际的数据,而只是“窃取”指向实际数据的指针(类似于STL中的auto_ptr,会转移所有权)。右值引用根据其修饰符的不同,也可以分为非常量右值引用和常量右值引用。
非常量右值引用:
非常量右值引用只能绑定到非常量右值,不能绑定到非常量左值、常量左值和常量右值(VS2010 beta版中可以绑定到非常量左值和常量左值,但正式版中为了安全起见,已不允许)。如果允许绑定到非常量左值,则可能会错误地窃取一个持久对象的数据,而这是非常危险的;如果允许绑定到常量左值和常量右值,则非常量右值引用可以用于修改常量左值和常量右值,这明显违反了其常量的含义。
常量右值引用:
常量右值引用可以绑定到非常量右值和常量右值,不能绑定到非常量左值和常量左值(理由同上)。可以看出,使用左值引用时,我们无法区分出绑定的是否是非常。
网络相关文章参考:关于左值和右值的Q & A
右值引用简介(中英文对照)