为什么 ++i 是左值而 i++ 不是?
11 回答
其他人已经解决了后增量和前增量之间的功能差异。
就左值而言,i++
不能分配给,因为它不引用变量。它指的是计算值。
在分配方面,以下两者以同样的方式没有意义:
i++ = 5;
i + 0 = 5;
因为 pre-increment 返回对递增变量的引用而不是临时副本,++i
所以是一个左值。
当您增加诸如迭代器对象(例如在 STL 中)之类的东西时,出于性能原因首选预增量成为一个特别好的主意,它可能比 int 更重量级。
好吧,正如另一个回答者已经指出的那样++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
第二条规则的原因是当引用是对 const 的引用时,允许使用文字来初始化引用:
void taking_refc(int const& v);
taking_refc(10); // valid, 10 is an rvalue though!
您可能会问,为什么我们要引入右值。好吧,在为这两种情况构建语言规则时会出现这些术语:
- 我们想要一个定位器值。这将表示一个包含可以读取的值的位置。
- 我们想要表示表达式的值。
以上两点取自 C99 标准,其中包含这个非常有帮助的漂亮脚注:
[ 名称“左值”最初来自赋值表达式 E1 = E2,其中要求左操作数 E1 是(可修改的)左值。将其视为表示对象“定位器值”可能更好。有时被称为“右值”的东西在本国际标准中被描述为“表达式的值”。]
定位器值称为lvalue,而评估该位置产生的值称为rvalue。根据 C++ 标准,这也是正确的(谈论左值到右值的转换):
4.1/2:左值表示的对象中包含的值是右值结果。
结论
使用上述语义,现在很清楚为什么i++
不是左值而是右值。因为返回的表达式不再位于i
其中(它是递增的!),它只是可能感兴趣的值。修改返回的值是i++
没有意义的,因为我们没有可以再次读取该值的位置。所以标准说它是一个右值,因此它只能绑定到一个对常量的引用。
但是,相比之下,返回的表达式++i
是 的位置(左值)i
。引发左值到右值的转换,例如 inint a = ++i;
会从中读取值。或者,我们可以对它做一个参考点,然后再读出它的值:int &a = ++i;
.
还要注意生成右值的其他场合。例如,所有临时变量都是右值,二进制/一元 + 和减号的结果以及所有不是引用的返回值表达式。所有这些表达式都不位于命名对象中,而是仅携带值。这些值当然可以由非恒定的对象支持。
下一个 C++ 版本将包括所谓的rvalue references
,即使它们指向非常量,也可以绑定到右值。基本原理是能够从这些匿名对象中“窃取”资源,并避免复制这样做。假设一个类类型具有重载的前缀 ++(返回Object&
)和后缀 ++(返回Object
),以下将首先导致复制,对于第二种情况,它将从右值窃取资源:
Object o1(++a); // lvalue => can't steal. It will deep copy.
Object o2(a++); // rvalue => steal resources (like just swapping pointers)
似乎很多人都在解释++i
左值是如何产生的,而不是为什么,例如,为什么C++ 标准委员会会加入这个特性,尤其是考虑到 C 不允许将左值作为左值的事实。从关于 comp.std.c++的讨论中,您可以获取它的地址或分配给参考。摘自 Christian Bau 帖子的代码示例:
诠释我; 外部无效 f (int* p); 外部无效 g (int& p); f (&++i); /* 将是非法的 C,但 C 程序员 没有错过这个功能 */ g (++i); /* C++ 程序员希望这是合法的 */ g (i++); /* 不是合法的 C++,而且很难 给出这个有意义的语义 */
顺便说一句,如果i
碰巧是一个内置类型,那么诸如++i = 10
调用未定义行为之类的赋值语句,因为i
在序列点之间被修改了两次。
尝试编译时出现左值错误
i++ = 2;
但不是当我将其更改为
++i = 2;
这是因为前缀运算符 (++i) 改变了 i 中的值,然后返回 i,所以它仍然可以被赋值。后缀运算符 (i++) 更改 i 中的值,但返回旧值的临时副本,赋值运算符无法修改该副本。
回答原始问题:
如果您正在谈论在自己的语句中使用增量运算符,例如在 for 循环中,那真的没有区别。Preincrement 似乎更有效,因为 postincrement 必须自增并返回一个临时值,但编译器会优化这种差异。
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;
是不同的。您选择使用哪个增量运算符作为多运算符语句的一部分取决于预期的行为。简而言之,不,你不能只选择一个。你必须了解两者。
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;
}
};
关于 LValue
在
C
(例如 Perl)中,LValue既不是++i
也不是。i++
在
C++
,i++
is not 和 LValue but++i
is.++i
等价于i += 1
,即等价于i = i + 1
。
结果是我们仍在处理同一个对象i
。
它可以被视为:int i = 0; ++i = 3; // is understood as i = i + 1; // i now equals 1 i = 3;
i++
另一方面可以看作:
首先我们使用 的值,i
然后增加对象i
。int i = 0; i++ = 3; // would be understood as 0 = 3 // Wrong! i = i + 1;
(编辑:在第一次尝试有污点后更新)。
主要区别在于 i++ 返回前增量值,而 ++i 返回后增量值。我通常使用 ++i ,除非我有非常令人信服的理由使用 i++ - 即,如果我确实需要预增量值。
恕我直言,使用 '++i' 形式是一种很好的做法。虽然在比较整数或其他 POD 时无法真正测量前增量和后增量之间的差异,但如果对象非常昂贵,则使用“i++”时必须创建和返回的额外对象副本可能会对性能产生重大影响复制或频繁增加。
顺便说一句 - 避免在同一语句中对同一变量使用多个增量运算符。至少在 C 中,您会陷入“序列点在哪里”和未定义的操作顺序的混乱。我认为其中一些在 Java 和 C# 中已被清除。
也许这与后增量的实现方式有关。也许是这样的:
- 在内存中创建原始值的副本
- 增加原始变量
- 退回副本
由于副本既不是变量也不是对动态分配内存的引用,因此它不能是左值。
编译器如何翻译这个表达式?a++
我们知道我们想要返回未递增的版本,即递增之前a
的旧版本。我们还希望将增量作为副作用。换句话说,我们返回的是旧版本的,它不再代表 的当前状态,它不再是变量本身。a
a
a
a
返回的值是被放入寄存器的副本。然后变量递增。所以在这里你不是返回变量本身,而是返回一个单独的实体的副本!该副本临时存储在寄存器中,然后返回。回想一下,C++ 中的左值是在内存中具有可识别位置的对象。但是副本存储在CPU 的寄存器中,而不是内存中。所有右值都是在内存中没有可识别位置的对象。这就解释了为什么旧版本的副本a
是一个右值,因为它被临时存储在寄存器中。通常,任何副本、临时值或长表达式的结果(5 + a) * b
都存储在寄存器中,然后将它们分配给变量,这是一个左值。
后缀运算符必须将原始值存储到寄存器中,以便它可以返回未递增的值作为其结果。考虑以下代码:
for (int i = 0; i != 5; i++) {...}
这个 for 循环最多可计数 5 个,但却i++
是最有趣的部分。它实际上是 1 中的两条指令。首先我们必须将 的旧值移动i
到寄存器中,然后我们递增i
。在伪汇编代码中:
mov i, eax
inc i
eax
注册现在包含i
作为副本的旧版本。如果变量i
驻留在主存中,CPU 可能会花费大量时间从主存中获取副本并将其移动到寄存器中。对于现代计算机系统来说,这通常非常快,但如果你的 for 循环迭代十万次,所有这些额外的操作就会开始加起来!这将是一个重大的性能损失。
现代编译器通常足够聪明,可以优化整数和指针类型的额外工作。对于更复杂的迭代器类型,或者可能是类类型,这种额外的工作可能成本更高。
那么前缀增量++a
呢?
我们要返回 的增量版本,增量之后a
的新版本。新版本代表 的当前状态,因为它是变量本身。a
a
a
首先a
是递增的。既然我们想得到 的更新版本a
,为什么不直接返回变量a
本身呢?我们不需要将临时副本复制到寄存器中来生成右值。这将需要不必要的额外工作。所以我们只是将变量本身作为左值返回。
如果我们不需要未递增的值,则不需要将旧版本的复制a
到寄存器中的额外工作,这是由后缀运算符完成的。这就是为什么你应该只a++
在你真的需要返回未增加的值时使用。对于所有其他目的,只需使用++a
. 通过习惯使用前缀版本,我们不必担心性能差异是否重要。
使用的另一个好处++a
是它更直接地表达了程序的意图:我只想递增a
!但是,当我看到a++
别人的代码时,我想知道他们为什么要返回旧值?它是干什么用的?
C#:
public void test(int n)
{
Console.WriteLine(n++);
Console.WriteLine(++n);
}
/* Output:
n
n+2
*/