为什么++i被认为是一个左值,但i++不是?

49
为什么++i是左值而i++不是?

2
注意:对象上的后增量是一个方法调用(而不是技术上的后增量),因此在技术上不受此规则的限制。请参见下面的讨论。 - Martin York
11个回答

36

其他人已经解决了后置自增和前置自增的函数差异。

左值而言,i++不能被赋值,因为它不是指向变量,而是指向计算出的值。

在赋值方面,以下两者都同样没有意义:

i++   = 5;
i + 0 = 5;

由于前缀递增返回的是递增后变量的引用而不是临时副本,因此++i是一个左值。

出于性能原因而更喜欢使用前缀递增,在递增诸如迭代器对象(例如在STL中)之类的重量级对象时尤其明智。


1
@Paul - 公平地说,最初的问题写法不同,似乎是在问人们已经回答了什么。 - Paul Tomblin
好的,我可能是在编辑前的前几分钟进来的,没有看到原始问题。我想在 Stack Overflow 上,在答案已经被接受后大幅修改问题应该算是不良惯例。 - Paul Stephenson
@Paul(嘿,变得有点混乱了)- 我没有看到原始消息,必须承认我有点困惑,为什么没有人解决lvalue问题。 - mackenir

27

正如另一个回答者已经指出的那样,++i 之所以是左值,是为了将其传递给引用。

int v = 0;
int const & rcv = ++v; // would work if ++v is an rvalue too
int & rv = ++v; // would not work if ++v is an rvalue

第二条规则的原因是允许在引用是对常量的引用时使用字面值来初始化引用。
void taking_refc(int const& v);
taking_refc(10); // valid, 10 is an rvalue though!

为什么我们要引入rvalue?你可能会问。好吧,在构建这两种情况的语言规则时,这些术语就会出现:
- 我们想要一个定位器值。它将表示包含可以读取的值的位置。 - 我们想要表示表达式的值。
上述两点摘自C99标准,其中包含了这个很有用的脚注:
“lvalue”这个名称最初来自赋值表达式E1=E2,在该表达式中,需要将左操作数E1作为(可修改的)lvalue。它或许更好地被认为是代表一个对象的“定位器值”。在这个国际标准中,有时所谓的“rvalue”被描述为“表达式的值”。
定位器值称为lvalue,而从评估该位置得出的值称为rvalue。根据C++标准(关于lvalue-to-rvalue转换),这是正确的:
4.1/2:由lvalue指示的对象中包含的值是rvalue结果。
结论
使用上述语义,现在很清楚为什么i++不是lvalue而是rvalue了。因为返回的表达式不再位于i中(它已经增加了!),它只是可以感兴趣的值。修改由i++返回的值是没有意义的,因为我们没有一个位置可以从中再次读取该值。因此,标准说它是rvalue,因此只能绑定到const引用。
然而,相反,由++i返回的表达式是i的位置(lvalue)。诱发lvalue-to-rvalue转换,例如在int a = ++i;中读取其值。或者,我们可以使一个引用指向它,并稍后读出该值:int &a = ++i;。
还要注意其他产生rvalue的场合。例如,所有临时变量都是rvalue,二元/一元+和-的结果以及所有不是引用的返回值表达式。所有这些表达式都不位于命名对象中,而仅携带值。当然,这些值可以由非常量对象支持。
下一个C++版本将包括所谓的“右值引用”,即使它们指向非常量,也可以绑定到右值。其目的是能够从这些匿名对象中“窃取”资源,并避免进行复制。假设有一个类类型,它重载了前缀++(返回Object&)和后缀++(返回Object),则以下操作将首先导致复制,而对于第二种情况,则会从右值中窃取资源:
Object o1(++a); // lvalue => can't steal. It will deep copy.
Object o2(a++); // rvalue => steal resources (like just swapping pointers)

1
我今天已经达到了200个点的限制。无论是社区还是其他地方,都没关系。那里有很多其他问题可以收集积分呢。嘿嘿。 - Johannes Schaub - litb
无论如何,如果这能更清晰地回答问题,我都会接受。 - yesraaj
顺便说一下,现在你明白了https://dev59.com/s3RC5IYBdhLWcg3wOOP1。传递引用只是意味着传递的是左值而不是右值。正如我们所看到的,这需要一个引用类型参数。 - Johannes Schaub - litb
int const & rcv = ++v; // would work if ++v is an rvalue too 是可以工作的,但是它会以不同的方式工作。特别地,int const & rcv = v++; 不会将一个引用绑定到 v 上,未来对 v 的更改在读取 rcv 时将不可见。 - Ben Voigt

10
似乎有很多人在解释为什么++i是lvalue,但是没有解释为什么,也就是说,为什么C++标准委员会加入了这个特性,特别是考虑到C语言都不允许这样做。根据comp.std.c++上的这次讨论,这样做可以让你取它的地址或者赋值给一个引用。以下是Christian Bau帖子中提取的代码示例:
   int i;
   extern void f (int* p);
   extern void g (int& p);
f (&++i); /* 在C语言中是非法的,但C程序员并不需要此功能 */ g (++i); /* C++程序员希望这是合法的 */ g (i++); /* 在C++中不合法,而且给这个具有实际意义将会很困难 */
顺便说一下,如果i是内置类型,那么类似++i = 10这样的赋值语句会导致未定义行为,因为i在序列点之间被修改了两次。

我猜测 CW 复选框默认已被选中,而我没有注意到。 - Nietzche-jou
CW是对CW问题答案的默认设置。 由于您多次编辑了问题,因此您的问题转换为CW状态。 我认为这个答案是在问题变成CW之后发布的。因此,它默认是CW状态。 - mackenir
最后一段(有关序列点的部分)很奇怪。您能提供此想法来源的链接吗? - user3458
在同一表达式中两次更新左值是未定义行为。编译器可以在两个序列点之间大幅度优化代码。参见:https://dev59.com/nnRC5IYBdhLWcg3wP-Zh - Martin York

6
当我尝试编译时,出现了lvalue错误。
i++ = 2;

但我把它改成时就不行了

++i = 2;

这是因为前缀运算符 (++i) 改变了 i 的值,然后返回 i,所以它仍然可以被赋值。后缀运算符 (i++) 改变了 i 的值,但返回旧值的临时副本,这个副本无法被赋值操作修改。

原问题的答案:

如果你在一个语句中单独使用增量运算符(比如在 for 循环中),那么两者并没有太大区别。前缀自增似乎更高效,因为后缀自增需要对自身进行增量并返回临时值,但编译器会优化这种差异。

for(int i=0; i<limit; i++)
...

等同于

for(int i=0; i<limit; ++i)
...

当您将操作的返回值用作较大语句的一部分时,情况会变得有些复杂。

即使是这两个简单的语句

int i = 0;
int a = i++;

并且

int i = 0;
int a = ++i;

这两种不同的增量运算符是不同的。在多个操作符语句中使用哪种增量运算符取决于预期的行为。简而言之,不能只选择其中一种。你必须了解两种。


4

POD前置自增:

前置自增应该在表达式之前对对象进行自增,并且可以在此表达式中使用,就好像已经发生了这种情况一样。因此,C++标准委员会决定它也可以用作左值。

POD后置自增:

后置自增应该增加POD对象并返回一个副本以用于表达式(请参见n2521第5.2.6节)。由于副本实际上不是变量,因此将其作为左值没有任何意义。

对象:

对象的前置和后置自增只是语言提供的一种调用对象方法的手段。因此,从技术上讲,对象不受语言标准行为的限制,而仅受方法调用所施加的限制。

实现这些方法的人需要使这些对象的行为反映出POD对象的行为(这不是必需的,但是预期这样做)。

对象前置自增:

这里的要求(预期行为)是增加对象(意味着依赖于对象),并且该方法返回一个可修改的值,看起来像在增加发生之前的原始对象(就像在此语句之前发生了增量一样)。

要做到这一点很简单,只需要使该方法返回对自身的引用。引用是一个左值,因此将按预期工作。

对象后置自增:

这里的要求(预期行为)是对象增量(与前置自增相同),并且返回的值看起来像旧值,并且是不可变的(以便它不像左值一样行为)。

不可变:
要做到这一点,应该返回一个对象。如果在表达式中使用该对象,则将其复制构造到临时变量中。临时变量是const的,因此它将是不可变的,并且将按预期工作。

看起来像旧值:
这可以通过在进行任何修改之前创建原始副本(可能使用复制构造函数)来简单实现。复制品应该是深度复制,否则对原始的任何更改都将影响复制品,因此状态将相对于使用对象的表达式而言发生变化。

与前置自增相同:
最好基于前置自增来实现后置自增,以便获得相同的行为。

class Node // Simple Example
{
     /*
      * Pre-Increment:
      * To make the result non-mutable return an object
      */
     Node operator++(int)
     {
         Node result(*this);   // Make a copy
         operator++();         // Define Post increment in terms of Pre-Increment

         return result;        // return the copy (which looks like the original)
     }

     /*
      * Post-Increment:
      * To make the result an l-value return a reference to this object
      */
     Node& operator++()
     {
         /*
          * Update the state appropriatetly */
         return *this;
     }
};

“预增量应该表现得好像在表达式之前对象已经被增加,并且可以在这个表达式中可用,就好像已经发生了这种情况。” 这句话让我感到困惑,因为让++i在某个表达式中可用听起来像是一段不好的代码,不是吗?它怎么可能成为C ++标准委员会的原因呢? - Kindred
@ptr_user7813604:从未见过这样的调用:doStuff(++i); 你想要先将 i 增加,然后将其作为参数传递给 doStuff()。相反,doStuff(i++) 增加了 i 的值,但是传递给 doStuff() 的是 i 的原始值(增加之前的值)。 - Martin York
我的意思是:为什么不先调用++ii++,然后再调用我在上面评论中提到的doStuff(i)呢?因为我引用的部分就像是在说“我们确保++i先完成,所以现在你可以调用doStuff(++i)这样的东西”,而在我看来,这是一段糟糕的代码。所以我在考虑不同的原因。 - Kindred
如果这句话中的两个expression指的是同一个表达式,那么从这个句子中的“...在表达式之前并且可以在该表达式中使用”意思来看,像i = ++i这样的表达式就是可取的,因为++i现在已经在表达式之前被增加了。我知道这个问题本身有点矛盾,因为在我看来,++i只是一个简写,使得任何事情都变得方便(但不一定易于理解),但我想委员会可能还有其他好的理由。抱歉让您读了这么长的内容。 - Kindred
@ptr_user7813604 那种情况是被禁止的,因为你不能在同一语句中两次修改同一个变量。 - Martin York

3

Regarding LValue

  • In C (and Perl for instance), neither ++i nor i++ are LValues.

  • In C++, i++ is not and LValue but ++i is.

    ++i is equivalent to i += 1, which is equivalent to i = i + 1.
    The result is that we're still dealing with the same object i.
    It can be viewed as:

    int i = 0;
    ++i = 3;  
    // is understood as
    i = i + 1;  // i now equals 1
    i = 3;
    

    i++ on the other hand could be viewed as:
    First we use the value of i, then increment the object i.

    int i = 0;
    i++ = 3;  
    // would be understood as 
    0 = 3  // Wrong!
    i = i + 1;
    
(编辑:第一次尝试失败后更新。)

在我的编译器中,“i++ = 5”是没有意义的。而“++i = 5”是可以的:你先将“i”加1,然后返回“i”,最后将其重新赋值为5。 - Paul Stephenson
1
@Paul:在同一表达式中进行递增和赋值操作是未定义行为。 - Martin York
@Paul 和 Martin:在我笨拙的尝试之后,我已经修改了我的帖子,并在更清醒的头脑下继续努力,比昨晚更加专注。 - Renaud Bompuis
@LokiAstari如果 (++i) = 5;,那么仍然是未定义的。括号会强制先进行递增运算吗? - v010dya
1
@Volodya:自那条评论以来,标准有些变化。它已经从“序列点”更改为“排序前”和“排序后”。但我认为相同的规则适用。在同一语句中对同一变量进行多次赋值是未定义行为。因此不,添加大括号并没有帮助。你为什么要那样写呢?即使含义被明确定义且有效;从程序员的角度来看,那也很难解析和理解。为什么不写成 i = <value> 呢? - Martin York

0

主要的区别在于i++返回前增量值,而++i返回后增量值。我通常使用++i,除非我有一个非常强烈的理由使用i++ - 也就是说,如果我真的需要前增量值。

在我看来,使用'++i'形式是一个好的实践。虽然在比较整数或其他POD时,前增量和后增量之间的差异并不真正可测量,但当使用'i++'时,你必须进行额外的对象复制并返回,如果对象要么很昂贵,要么经常增加,这可能会对性能产生重大影响。


真的吗?我在思考如果一个表达式只是为了自增,编译器是否会将 i++++i 视为相同。 - Kindred

0
顺便提一下 - 避免在同一语句中对同一变量使用多个增量运算符。这会导致“序列点在哪里”和操作顺序未定义的混乱,至少在C语言中是如此。我认为Java和C#中已经解决了其中的一些问题。

2
在C和C++中,使用多个增量运算符对同一变量进行操作且没有序列点的行为是未定义的。Java和C#可能已经定义了这种行为,我不确定。我不会称之为“清理代码”,也不会编写这样的代码。 - David Thornley

0

也许这与后置递增的实现方式有关。可能是像这样的:

  • 在内存中创建原始值的副本
  • 增加原始变量
  • 返回副本

由于该副本既不是变量,也不是动态分配内存的引用,因此它不能是左值。


为什么这个被踩了?这是这里最清晰的解释之一。 - v010dya
@Volodya:我不能代表那个点踩者回答,但是回想起来,我也不太喜欢我的这个答案。它假设了一个固定的前/后缀递增运算符实现。它假设编译器不进行优化。更糟糕的是,措辞明显不准确:“动态分配”的说法应该被省略。在2008年,我很容易根据我观察到的两个实现(MSVC和GCC)的语义做出关于C和C++的假设。在2015年,我知道我必须阅读标准,或者更好的是,把这些问题留给专业人士来回答。 - isekaijin
是的,“动态分配”确实是一个问题。但通常一个人会寻找一个快速而简单的答案,而“这里有两页文字”实际上并不是非常有用的(我并不是说长篇回答是错误的,而是适合不同的人)。 - v010dya

0

编译器如何翻译这个表达式?a++

我们知道我们想要返回未递增的版本a,即在递增之前的旧版本a。我们还希望通过副作用递增a。换句话说,我们返回的是a的旧版本,它不再代表a的当前状态,它不再是变量本身。

返回的值是一个 a 的副本,它被放置在一个寄存器中。然后变量被递增。所以在这里你不是返回变量本身,而是返回一个分离的实体!这个副本临时存储在一个寄存器中,然后被返回。回想一下,在C++中,一个左值(lvalue)是一个在内存中有可识别位置的对象。但是这个副本存储在CPU的一个寄存器中,而不是内存中。所有的右值(rvalue)都是没有可识别位置的对象。这就解释了为什么 a 的旧版本的副本是一个右值,因为它被临时存储在一个寄存器中。通常情况下,任何副本、临时值或者类似 (5 + a) * b 这样的复杂表达式的结果都会被存储在寄存器中,然后被赋给变量,它是一个左值。

后缀操作符必须将原始值存储在寄存器中,以便可以将未递增的值作为其结果返回。 考虑以下代码:

for (int i = 0; i != 5; i++) {...}

这个 for 循环计数到五,但是 i++ 是最有趣的部分。它实际上是两条指令合并成一条。首先,我们必须将 i 的旧值移动到寄存器中,然后再将 i 递增。在伪汇编代码中:

mov i, eax
inc i

eax 寄存器现在包含了变量 i 的旧版本的副本。如果变量 i 存储在主内存中,CPU 可能需要花费很长时间去获取这个副本并将其移动到寄存器中。对于现代计算机系统来说,这通常非常快速,但如果您的 for 循环迭代了十万次,所有这些额外操作开始累积起来!这将导致显著的性能损失。

现代编译器通常足够聪明,可以优化整数和指针类型的这些额外工作。对于更复杂的迭代器类型或类类型,这些额外工作可能会更加昂贵。

那么前缀递增 ++a 呢?

我们想要返回已经递增的版本 a,即递增后的新版本 a。新版本的 a 表示 a 的当前状态,因为它就是变量本身。

首先,a 被增加了。既然我们想要得到更新后的 a,为什么不直接返回变量 a 本身 呢?我们不需要将其复制到寄存器中生成 rvalue 的临时副本。那样会多做无用功。因此,我们只需将变量本身作为 lvalue 返回。

如果我们不需要未增加的值,就没有必要将旧版本的 a 复制到寄存器中,这是由后缀运算符完成的。这就是为什么只有在真正需要返回未增加的值时才应该使用 a++。对于其他所有目的,只需使用 ++a。通过习惯性地使用前缀版本,我们就不必担心性能差异是否重要。

使用 ++a 的另一个优点是它更直接地表达了程序的意图:我只想增加 a!然而,当我在别人的代码中看到 a++ 时,我会想知道他们为什么要返回旧值?这是为什么呢?


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接