提问人:PiX 提问时间:6/4/2009 最后编辑:Antti Haapala -- Слава УкраїніPiX 更新时间:10/7/2023 访问量:100169
为什么这些构造使用增量前和增量后未定义的行为?
Why are these constructs using pre and post-increment undefined behavior?
问:
#include <stdio.h>
int main(void)
{
int i = 0;
i = i++ + ++i;
printf("%d\n", i); // 3
i = 1;
i = (i++);
printf("%d\n", i); // 2 Should be 1, no ?
volatile int u = 0;
u = u++ + ++u;
printf("%d\n", u); // 1
u = 1;
u = (u++);
printf("%d\n", u); // 2 Should also be one, no ?
register int v = 0;
v = v++ + ++v;
printf("%d\n", v); // 3 (Should be the same as u ?)
int w = 0;
printf("%d %d\n", ++w, w); // shouldn't this print 1 1
int x[2] = { 5, 8 }, y = 0;
x[y] = y ++;
printf("%d %d\n", x[0], x[1]); // shouldn't this print 0 8? or 5 0?
}
答:
C 语言具有未定义行为的概念,即某些语言结构在语法上是有效的,但您无法预测代码运行时的行为。
据我所知,该标准没有明确说明为什么存在未定义行为的概念。在我看来,这仅仅是因为语言设计者希望在语义上有一些回旋余地,而不是要求所有实现都以完全相同的方式处理整数溢出,这很可能会带来严重的性能成本,他们只是将行为保留为未定义,以便如果您编写导致整数溢出的代码, 任何事情都可能发生。
那么,考虑到这一点,为什么会出现这些“问题”呢?语言清楚地表明,某些事情会导致未定义的行为。没有问题,不涉及“应该”。如果未定义的行为在声明其中一个涉及的变量时发生变化,则不会证明或更改任何内容。它是未定义的;你无法对行为进行推理。volatile
你看起来最有趣的例子,那个
u = (u++);
是未定义行为的教科书示例(参见维基百科关于序列点的条目)。
评论
i = ++i + 1;
我认为 C99 标准的相关部分是 6.5 表达式,§2
在上一个和下一个序列点之间,对象应具有其存储值 通过对表达式的计算,最多修改一次。此外,先验值 应为只读以确定要存储的值。
和 6.5.16 赋值运算符,§4:
操作数的计算顺序未指定。如果尝试修改 赋值运算符的结果,或者在下一个序列点之后访问它, 行为未定义。
评论
i=i=5
A=B=5;
C=A+B;
A=B=5;
C=A+B;
I=I=5;
只需编译和反汇编你的代码行,如果你非常倾向于知道它到底是怎么回事,你就会得到你所得到的。
这是我在我的机器上得到的,以及我认为正在发生的事情:
$ cat evil.c
void evil(){
int i = 0;
i+= i++ + ++i;
}
$ gcc evil.c -c -o evil.bin
$ gdb evil.bin
(gdb) disassemble evil
Dump of assembler code for function evil:
0x00000000 <+0>: push %ebp
0x00000001 <+1>: mov %esp,%ebp
0x00000003 <+3>: sub $0x10,%esp
0x00000006 <+6>: movl $0x0,-0x4(%ebp) // i = 0 i = 0
0x0000000d <+13>: addl $0x1,-0x4(%ebp) // i++ i = 1
0x00000011 <+17>: mov -0x4(%ebp),%eax // j = i i = 1 j = 1
0x00000014 <+20>: add %eax,%eax // j += j i = 1 j = 2
0x00000016 <+22>: add %eax,-0x4(%ebp) // i += j i = 3
0x00000019 <+25>: addl $0x1,-0x4(%ebp) // i++ i = 4
0x0000001d <+29>: leave
0x0000001e <+30>: ret
End of assembler dump.
(我......假设0x00000014指令是某种编译器优化?
评论
gcc evil.c -c -o evil.bin
gdb evil.bin
disassemble evil
Why are these constructs undefined behavior?
gcc -S evil.c
虽然任何编译器和处理器都不太可能真正这样做,但在 C 标准下,编译器使用以下序列实现“i++”是合法的:
In a single operation, read `i` and lock it to prevent access until further notice
Compute (1+read_value)
In a single operation, unlock `i` and store the computed value
虽然我不认为任何处理器支持硬件来有效地完成这样的事情,但人们可以很容易地想象这样的行为会使多线程代码更容易的情况(例如,如果两个线程尝试同时执行上述序列,它将保证增加两个),并且一些未来的处理器可能会提供类似的功能,这并非完全不可想象。i
如果编译器按照上述方式编写(根据标准是合法的),并且在整个表达式的评估过程中穿插上述指令(也是合法的),并且如果它碰巧没有注意到其他指令之一碰巧访问,那么编译器就有可能(并且合法)生成一系列指令,从而死锁。可以肯定的是,在两个地方都使用同一个变量的情况下,编译器几乎肯定会检测到问题,但是如果例程接受对两个指针和 的引用,并在上述表达式中使用 and(而不是使用两次),则编译器将不需要识别或避免在为 和 传递相同对象的地址时发生的死锁。i++
i
i
p
q
(*p)
(*q)
i
p
q
这种行为无法真正解释,因为它同时调用了未指定的行为和未定义的行为,因此我们无法对此代码做出任何一般预测,尽管如果您阅读 Olve Maudal 的工作,例如 Deep C 和 Unspecified and Undefined,有时您可以使用特定的编译器和环境在非常特定的情况下做出正确的猜测,但请不要在生产附近的任何地方这样做。
因此,在草案 c99 标准第 3 节中,继续讨论未指定的行为(强调我的):6.5
运算符和操作数的分组由语法指示。74) 除非另有规定 后来(对于函数调用 ()、&&、||、?: 和逗号运算符),子表达式的计算顺序和副作用发生的顺序都未指定。
因此,当我们有这样的行时:
i = i++ + ++i;
我们不知道是否或将首先进行评估。这主要是为了给编译器提供更好的优化选项。i++
++i
我们在这里也有未定义的行为,因为程序在序列点之间多次修改变量(、等)。来自草案标准第2段(强调我的):i
u
6.5
在上一个和下一个序列点之间,对象应具有其存储值 通过对表达式的计算,最多修改一次。此外,先验值 应为只读以确定要存储的值。
它引用以下代码示例作为未定义:
i = ++i + 1;
a[i++] = i;
在所有这些示例中,代码都尝试在同一序列点中多次修改对象,在以下每种情况下,该序列点都将以 结尾:;
i = i++ + ++i;
^ ^ ^
i = (i++);
^ ^
u = u++ + ++u;
^ ^ ^
u = (u++);
^ ^
v = v++ + ++v;
^ ^ ^
在 c99 标准草案的章节中,未指定的行为定义为:3.4.4
使用未指定的值,或本国际标准规定的其他行为 两种或两种以上的可能性,并且没有对在任何选择中选择的进一步要求 实例
未定义的行为在本节中定义为:3.4.3
使用不可移植或错误的程序结构或错误数据的行为, 本国际标准对此没有要求
并指出:
可能的未定义行为包括完全忽略结果不可预测的情况,到在翻译或程序执行过程中以环境特征的记录方式(有或没有发出诊断消息)的行为,再到终止翻译或执行(发出诊断消息)。
C 标准规定,一个变量最多只能在两个序列点之间分配一次。例如,分号就是一个序列点。
所以每句话的形式:
i = i++;
i = i++ + ++i;
等等违反了该规则。该标准还指出,行为是未定义的,而不是未指定的。一些编译器确实会检测到这些并产生一些结果,但这不是符合标准的。
但是,可以在两个序列点之间递增两个不同的变量。
while(*src++ = *dst++);
以上是复制/分析字符串时常见的编码做法。
评论
在 https://stackoverflow.com/questions/29505280/incrementing-array-index-in-c 中,有人问过这样的陈述:
int k[] = {0,1,2,3,4,5,6,7,8,9,10};
int i = 0;
int num;
num = k[++i+k[++i]] + k[++i];
printf("%d", num);
打印 7...OP 预计它会打印 6。
不能保证在其余计算之前完成所有增量。事实上,不同的编译器在这里会得到不同的结果。在您提供的示例中,执行了前 2 个,然后读取了 的值,然后读取了最后一个 。++i
++i
k[]
++i
k[]
num = k[i+1]+k[i+2] + k[i+3];
i += 3
现代编译器将很好地优化这一点。事实上,可能比你最初编写的代码更好(假设它按照你希望的方式工作)。
回答这个问题的另一种方法,而不是陷入序列点和未定义行为的神秘细节中,而是简单地问,它们应该意味着什么? 程序员想做什么?
在我的书中,第一个被问到的片段显然是疯狂的。没有人会在真正的程序中编写它,它的作用并不明显,没有可以想象的算法可以有人试图编码,从而导致这种特定的人为操作序列。由于你和我都不清楚它应该做什么,所以在我的书中,如果编译器也不能弄清楚它应该做什么,那也没关系。i = i++ + ++i
第二个片段 ,更容易理解。看起来有人试图递增,并将结果分配回 。但是在 C 语言中有几种方法可以做到这一点。取 的值、加 1 并将结果赋回 的最基本方法在几乎任何编程语言中都是一样的:i = i++
i
i
i
i
i = i + 1
当然,C 有一个方便的快捷方式:
i++
这也意味着,“取 的值,加 1,然后将结果赋回 ”。因此,如果我们构建两者的大杂烩,通过编写i
i
i = i++
我们真正想说的是“取 的值,加上 1,将结果赋给 ,然后将结果赋给 ”。我们很困惑,所以如果编译器也感到困惑,也不会太困扰我。i
i
i
实际上,这些疯狂的表达方式唯一一次被写出来的时候,是人们将它们用作应该如何工作的人为例子。当然,了解其工作原理也很重要。但一个实用的使用 规则是,“如果表达式 using 的含义不明显,就不要写它。++
++
++
++
我们曾经在 comp.lang.c 上花费无数时间讨论这样的表达式以及为什么它们未定义。我的两个较长的答案,试图真正解释为什么,都存档在网络上:
另请参阅问题 3.8 和 C 常见问题解答列表第 3 节中的其余问题。
评论
*p=(*q)++;
if (p!=q) *p=(*q)++; else *p= __ARBITRARY_VALUE;
*p
else
if
assert
assert(p != q)
<assert.h>
__builtin_assert_disabled()
i
i
i
++i
i++
i + 1
这里引用的大多数答案都来自 C 标准,强调这些结构的行为是未定义的。为了理解为什么这些结构的行为是未定义的,让我们首先根据 C11 标准来理解这些术语:
排序:(5.1.2.3)
给定任意两个计算值 和 ,如果排序在 之前,则 的执行应先于 的执行。
A
B
A
B
A
B
未排序:
如果 之前或之后没有排序,则 和 是未排序的。
A
B
A
B
评估可以是以下两种情况之一:
- 值计算,计算出表达式的结果;和
- 副作用,即对象的修改。
序列点:
表达式的计算和 之间存在序列点,意味着与每个值计算和副作用相关联的 值计算和副作用都先于与 关联的每个值计算和副作用进行排序。
A
B
A
B
现在来谈谈这个问题,对于像这样的表达方式
int i = 1;
i = i++;
标准说:
6.5 表达式:
如果标量对象上的副作用相对于同一标量对象上的不同副作用或使用同一标量对象的值进行值计算是未排序的,则行为未定义。[...]
因此,上面的表达式调用 UB,因为同一对象上的两个副作用相对于彼此是未排序的。这意味着没有排序通过赋值到的副作用是在副作用之前还是之后完成的。
根据赋值是在增量之前还是之后发生,将产生不同的结果,这就是未定义行为的情况之一。i
i
++
让我们将赋值左边的 be 和赋值的右边(在表达式中)重命名为 be ,则表达式类似于i
il
i++
ir
il = ir++ // Note that suffix l and r are used for the sake of clarity.
// Both il and ir represents the same object.
关于 Postfix 运算符的一个重要点是:++
仅仅因为
++
在变量之后并不意味着增量发生得很晚。只要编译器确保使用原始值,增量就可以在编译器喜欢的范围内最早发生。
这意味着表达式可以计算为il = ir++
temp = ir; // i = 1
ir = ir + 1; // i = 2 side effect by ++ before assignment
il = temp; // i = 1 result is 1
或
temp = ir; // i = 1
il = temp; // i = 1 side effect by assignment before ++
ir = ir + 1; // i = 2 result is 2
导致两种不同的结果,并且取决于分配的副作用序列,因此调用 UB。1
2
++
通常,这个问题是与代码相关的问题的重复项,例如
printf("%d %d\n", i, i++);
或
printf("%d %d\n", ++i, i++);
或类似的变体。
虽然这也是已经说明的未定义行为,但与以下语句相比,涉及时存在细微的差异:printf()
x = i++ + i++;
在以下语句中:
printf("%d %d\n", ++i, i++);
中参数的计算顺序未指定。这意味着,表达式可以按任何顺序计算。C11标准对此有一些相关描述:printf()
i++
++i
附件J,未指明的行为
函数指示符、参数和 参数中的子表达式在函数调用中计算 (6.5.2.2).
3.4.4、未指明的行为
使用未指定的值,或在此情况下的其他行为 国际标准提供了两种或多种可能性,并强加了 在任何情况下都没有进一步的要求。
示例 未指定行为的一个示例是 计算函数的参数。
未指定的行为本身不是问题。请看这个例子:
printf("%d %d\n", ++x, y++);
这也具有未指定的行为,因为 和 的计算顺序是未指定的。但这是完全合法和有效的声明。此语句中没有未定义的行为。因为修改 ( 和 ) 是对不同的对象进行的。++x
y++
++x
y++
是什么呈现了以下语句
printf("%d %d\n", ++i, i++);
因为未定义的行为是这两个表达式在没有干预序列点的情况下修改同一个对象。i
另一个细节是 printf() 调用中涉及的逗号是分隔符,而不是逗号运算符。
这是一个重要的区别,因为逗号运算符确实在计算其操作数之间引入了一个序列点,这使得以下内容合法:
int i = 5;
int j;
j = (++i, i++); // No undefined behaviour here because the comma operator
// introduces a sequence point between '++i' and 'i++'
printf("i=%d j=%d\n",i, j); // prints: i=7 j=6
逗号运算符从左到右计算其操作数,并仅生成最后一个操作数的值。因此,在 中,递增并产生分配给 的 () 的旧值。然后成为由于后增量。j = (++i, i++);
++i
i
6
i++
i
6
j
i
7
因此,如果函数调用中的逗号是逗号运算符,则
printf("%d %d\n", ++i, i++);
不会有问题。但它调用了未定义的行为,因为这里的逗号是一个分隔符。
对于那些不熟悉未定义行为的人来说,阅读《每个 C 程序员都应该了解的未定义行为》将受益匪浅,以了解 C 语言中未定义行为的概念和许多其他变体。
这篇文章:未定义、未指定和实现定义的行为也是相关的。
评论
int a = 10, b = 20, c = 30; printf("a=%d b=%d c=%d\n", (a = a + b + c), (b = b + b), (c = c + c));
b
c
-Wsequence-point
虽然表达式的语法是合法的,但这些构造的行为是不确定的,因为 C 标准中的 shall 没有被遵守。C99 6.5p2:a = a++
a++ + a++
- 在上一个和下一个序列点之间,对象的存储值最多应通过计算表达式修改一次。[72]此外,先前的值应仅读以确定要存储的值[73]
脚注73进一步澄清
本段呈现未定义的语句表达式,例如
i = ++i + 1; a[i++] = i;
同时允许
i = i + 1; a[i] = i;
以下是 5.1.2.3 中描述的序列点:
- 在函数调用和实际调用中函数指示符和实际参数的计算之间。(6.5.2.2).
- 在以下运算符的第一和第二操作数的计算之间:逻辑 AND && (6.5.13);逻辑 OR ||(6.5.14);逗号 (6.5.17)。
- 在条件的第一个操作数的评估之间?:运算符以及计算的第二个和第三个操作数中的哪一个 (6.5.15)。
- 完整声明符的结尾:声明符 (6.7.6);
- 在计算完整表达式和要计算的下一个完整表达式之间。以下是完整的表达式:不属于复合文本的初始值设定项 (6.7.9);表达式语句中的表达式 (6.8.3);选择语句(IF 或 SWITCH)的控制表达式 (6.8.4);while 或 do 语句的控制表达式 (6.8.5);for 语句的每个(可选)表达式 (6.8.5.3);return 语句 (6.8.6.4) 中的(可选)表达式。
- 紧接在库函数返回之前 (7.1.4)。
- 在与每个格式化的输入/输出函数转换说明符(7.21.6、7.29.2)关联的操作之后。
- 在每次调用比较函数之前和之后,以及对比较函数的任何调用与作为参数传递给该调用的对象的任何移动之间 (7.22.5)。
C11中同一段的措辞是:
- 如果标量对象上的副作用相对于同一标量对象上的不同副作用或使用同一标量对象的值进行值计算是未排序的,则行为未定义。如果表达式的子表达式有多个允许的排序,则如果在任何排序中发生这种未排序的副作用,则行为是未定义的84)。
例如,您可以使用带有 和 的最新版本的 GCC 来检测程序中的此类错误,然后 GCC 将直接拒绝编译您的程序。以下是gcc(Ubuntu 6.2.0-5ubuntu12)6.2.0 20161005的输出:-Wall
-Werror
% gcc plusplus.c -Wall -Werror -pedantic
plusplus.c: In function ‘main’:
plusplus.c:6:6: error: operation on ‘i’ may be undefined [-Werror=sequence-point]
i = i++ + ++i;
~~^~~~~~~~~~~
plusplus.c:6:6: error: operation on ‘i’ may be undefined [-Werror=sequence-point]
plusplus.c:10:6: error: operation on ‘i’ may be undefined [-Werror=sequence-point]
i = (i++);
~~^~~~~~~
plusplus.c:14:6: error: operation on ‘u’ may be undefined [-Werror=sequence-point]
u = u++ + ++u;
~~^~~~~~~~~~~
plusplus.c:14:6: error: operation on ‘u’ may be undefined [-Werror=sequence-point]
plusplus.c:18:6: error: operation on ‘u’ may be undefined [-Werror=sequence-point]
u = (u++);
~~^~~~~~~
plusplus.c:22:6: error: operation on ‘v’ may be undefined [-Werror=sequence-point]
v = v++ + ++v;
~~^~~~~~~~~~~
plusplus.c:22:6: error: operation on ‘v’ may be undefined [-Werror=sequence-point]
cc1: all warnings being treated as errors
重要的部分是要知道什么是序列点,什么是序列点,什么不是。例如,逗号运算符是一个序列点,因此
j = (i ++, ++ i);
定义明确,并且将递增 1,产生旧值,丢弃该值;然后在逗号运算符处,解决副作用;然后递增 1,结果值就变成了表达式的值——也就是说,这只是一种人为的写作方式,又是一种“聪明”的写作方式i
i
j = (i += 2)
i += 2;
j = i;
但是,in 函数参数列表不是逗号运算符,并且不同参数的计算之间没有序列点;相反,他们的评价彼此之间是没有顺序的;所以函数调用,
int i = 0;
printf("%d %d\n", i++, ++i, i);
具有未定义的行为,因为在函数参数中 I++
和 ++I
的计算之间没有序列点,因此 的值在前一个和下一个序列点之间被 和 修改了两次。i
i++
++i
原因是程序正在运行未定义的行为。问题在于计算顺序,因为根据 C++98 标准不需要序列点(根据 C++11 术语,没有操作在另一个操作之前或之后排序)。
但是,如果你坚持使用一个编译器,你会发现这种行为是持久的,只要你不添加函数调用或指针,这会使行为更加混乱。
使用 Nuwen MinGW 15 GCC 7.1,您将获得:
#include<stdio.h>
int main(int argc, char ** argv)
{
int i = 0;
i = i++ + ++i;
printf("%d\n", i); // 2
i = 1;
i = (i++);
printf("%d\n", i); //1
volatile int u = 0;
u = u++ + ++u;
printf("%d\n", u); // 2
u = 1;
u = (u++);
printf("%d\n", u); //1
register int v = 0;
v = v++ + ++v;
printf("%d\n", v); //2
}
GCC是如何运作的?它按从左到右的顺序计算右侧 (RHS) 的子表达式,然后将值分配给左侧 (LHS)。这正是 Java 和 C# 的行为方式和定义它们的标准。(是的,Java 和 C# 中的等效软件定义了行为)。它以从左到右的顺序逐个计算 RHS 语句中的每个子表达式;对于每个子表达式:首先计算 ++c(预增量),然后将值 c 用于操作,然后计算后增量 C++)。
根据 GCC C++:运算符
在 GCC C++ 中,运算符的优先级控制 对各个操作员进行评估
GCC 理解的定义行为 C++ 中的等效代码:
#include<stdio.h>
int main(int argc, char ** argv)
{
int i = 0;
//i = i++ + ++i;
int r;
r=i;
i++;
++i;
r+=i;
i=r;
printf("%d\n", i); // 2
i = 1;
//i = (i++);
r=i;
i++;
i=r;
printf("%d\n", i); // 1
volatile int u = 0;
//u = u++ + ++u;
r=u;
u++;
++u;
r+=u;
u=r;
printf("%d\n", u); // 2
u = 1;
//u = (u++);
r=u;
u++;
u=r;
printf("%d\n", u); // 1
register int v = 0;
//v = v++ + ++v;
r=v;
v++;
++v;
r+=v;
v=r;
printf("%d\n", v); //2
}
然后我们转到 Visual Studio。Visual Studio 2015,你将获得:
#include<stdio.h>
int main(int argc, char ** argv)
{
int i = 0;
i = i++ + ++i;
printf("%d\n", i); // 3
i = 1;
i = (i++);
printf("%d\n", i); // 2
volatile int u = 0;
u = u++ + ++u;
printf("%d\n", u); // 3
u = 1;
u = (u++);
printf("%d\n", u); // 2
register int v = 0;
v = v++ + ++v;
printf("%d\n", v); // 3
}
Visual Studio 是如何工作的,它采用另一种方法,它在第一遍中计算所有增量前表达式,然后在第二遍操作中使用变量值,在第三遍中从 RHS 赋值到 LHS,然后在最后一遍中计算所有增量后表达式。
因此,Visual C++所理解的定义行为C++等效的:
#include<stdio.h>
int main(int argc, char ** argv)
{
int r;
int i = 0;
//i = i++ + ++i;
++i;
r = i + i;
i = r;
i++;
printf("%d\n", i); // 3
i = 1;
//i = (i++);
r = i;
i = r;
i++;
printf("%d\n", i); // 2
volatile int u = 0;
//u = u++ + ++u;
++u;
r = u + u;
u = r;
u++;
printf("%d\n", u); // 3
u = 1;
//u = (u++);
r = u;
u = r;
u++;
printf("%d\n", u); // 2
register int v = 0;
//v = v++ + ++v;
++v;
r = v + v;
v = r;
v++;
printf("%d\n", v); // 3
}
正如 Visual Studio 文档在优先级和评估顺序中所述:
当多个运算符一起出现时,它们具有相同的优先级,并根据它们的关联性进行评估。表中的运算符在以后缀运算符开头的部分中进行了描述。
评论
ISO W14 站点的文档 n1188 中提供了关于这种计算中发生的情况的良好解释。
我解释这些想法。
适用于这种情况的 ISO 9899 标准中的主要规则是 6.5p2。
在上一个和下一个序列点之间,对象的存储值最多应通过计算表达式修改一次。此外,先前的值应仅读以确定要存储的值。
表达式中的序列点 like 是 之前和之后。i=i++
i=
i++
在我上面引用的论文中,它解释说,你可以把程序看作是由小盒子组成的,每个盒子都包含2个连续序列点之间的指令。序列点在标准的附录 C 中定义,如果有 2 个序列点分隔完整表达式。这种表达在语法上等同于语法的Backus-Naur形式的条目(语法在标准的附件A中提供)。i=i++
expression-statement
因此,盒子内的指令顺序没有明确的顺序。
i=i++
可以解释为
tmp = i
i=i+1
i = tmp
或作为
tmp = i
i = tmp
i=i+1
由于用于解释代码的所有这些形式都是有效的,并且都生成不同的答案,因此行为是未定义的。i=i++
因此,通过组成程序的每个框的开头和结尾可以看到序列点[这些框是 C 中的原子单元],并且在一个框内,指令的顺序并不是在所有情况下都定义的。改变这个顺序有时会改变结果。
编辑:
解释这种歧义的其他好来源是 c-faq 网站(也作为一本书出版)的条目,即 here 和 here 和 here 。
评论
你的问题可能不是,“为什么这些结构在 C 中是未定义的行为?你的问题可能是,“为什么这个代码(使用)没有给我预期的价值?”,有人把你的问题标记为重复,然后把你送到这里。++
这个答案试图回答这个问题:为什么你的代码没有给你预期的答案,你如何学会识别(和避免)那些不能按预期工作的表达式。
我假设您现在已经听说了 C 和运算符的基本定义,以及前缀形式与后缀形式有何不同。但是这些运算符很难考虑,所以为了确保你理解,也许你写了一个小小的测试程序,涉及类似的东西++
--
++x
x++
int x = 5;
printf("%d %d %d\n", x, ++x, x++);
但是,令你惊讶的是,这个程序并没有帮助你理解——它打印了一些奇怪的、莫名其妙的输出,表明它可能做了一些完全不同的事情,根本不是你想象的那样。++
或者,也许你正在看一个难以理解的表达,比如
int x = 5;
x = x++ + ++x;
printf("%d\n", x);
也许有人给了你那个代码作为谜题。这段代码也毫无意义,特别是如果你运行它——如果你在两个不同的编译器下编译和运行它,你可能会得到两个不同的答案!这是怎么回事?哪个答案是正确的?(答案是他们俩都是,或者他们都不是。
正如你现在所听到的,这些表达式是未定义的,这意味着 C 语言不能保证它们会做什么。这是一个奇怪而令人不安的结果,因为您可能认为您可以编写的任何程序,只要它编译并运行,就会生成唯一且定义明确的输出。但在未定义行为的情况下,情况并非如此。
是什么使表达式未定义?表达式是否涉及并且总是未定义?当然不是:这些都是有用的运算符,如果你正确使用它们,它们的定义就非常明确。++
--
对于我们正在谈论的表达式,使它们无法定义的是,当同时发生太多事情时,当我们无法判断事情会以什么顺序发生时,但是当顺序对我们得到的结果很重要时。
让我们回到我在这个答案中使用的两个例子。当我写的时候
printf("%d %d %d\n", x, ++x, x++);
问题是,在实际调用之前,编译器是否计算 first 或 或 may 的值?但事实证明我们不知道。C 中没有规则说函数的参数按从左到右、从右到左或其他顺序计算。因此,我们不能说编译器是先做,然后是,然后是,还是先做,或者其他顺序。但顺序显然很重要,因为根据编译器使用的顺序,我们显然会得到一系列不同的数字。printf
x
x++
++x
x
++x
x++
x++
++x
x
这种疯狂的表情呢?
x = x++ + ++x;
这个表达式的问题在于它包含三种不同的修改值的尝试:(1)该部分试图取 的值,加上 1,将新值存储在 中,然后返回旧值;(2)该部分试图取 的值,加 1,将新值存储在 中,并返回新值;(3) 该部分试图将其他两个的总和赋值回 。这三个尝试的任务中哪一个会“获胜”?这三个值中的哪一个将实际决定 的最终值?同样,也许令人惊讶的是,C 语言中没有规则可以告诉我们。x
x++
x
x
++x
x
x
x =
x
x
你可能会认为优先级、关联性或从左到右的评估会告诉你事情发生的顺序,但事实并非如此。你可能不相信我,但请相信我的话,我会再说一遍:优先级和关联性并不能决定 C 语言中表达式的计算顺序的每个方面。特别是,如果在一个表达式中有多个不同的点,我们试图为类似 、 优先级和关联性 之类的东西赋值,则不会告诉我们哪些尝试首先发生,哪些是最后发生,或者其他什么。x
因此,在排除了所有这些背景和介绍之后,如果您想确保所有程序都定义良好,您可以编写哪些表达式,哪些不能编写?
这些表达式都很好:
y = x++;
z = x++ + y++;
x = x + 1;
x = a[i++];
x = a[i++] + b[j++];
x[i++] = a[j++] + b[k++];
x = *p++;
x = *p++ + *q++;
这些表达式都是未定义的:
x = x++;
x = x++ + ++x;
y = x + x++;
a[i] = i++;
a[i++] = i;
printf("%d %d %d\n", x, ++x, x++);
最后一个问题是,如何判断哪些表达式是定义明确的,哪些表达式是未定义的?
正如我之前所说,未定义的表达式是那些同时发生太多事情的表达式,你无法确定事情发生的顺序,以及顺序很重要的表达式:
- 如果有一个变量在两个或多个不同的地方被修改(赋值),你怎么知道哪个修改先发生?
- 如果有一个变量在一个地方被修改,并在另一个地方使用其值,你怎么知道它是使用旧值还是新值?
作为 #1 的示例,在表达式中
x = x++ + ++x;
有三种修改尝试。x
作为 #2 的示例,在表达式中
y = x + x++;
我们都使用 的值 ,并对其进行修改。x
这就是答案:确保在你编写的任何表达式中,每个变量最多被修改一次,如果一个变量被修改,你也不要试图在其他地方使用该变量的值。
还有一件事。您可能想知道如何“修复”我通过介绍的开头给出的未定义表达式。
在 的情况下,这很简单——只需将其写成三个单独的调用:printf("%d %d %d\n", x, ++x, x++);
printf
printf("%d ", x);
printf("%d ", ++x);
printf("%d\n", x++);
现在行为已经完全定义好了,你会得到合理的结果。
另一方面,在 的情况下,没有办法修复它。没有办法编写它来保证它的行为符合你的期望——但这没关系,因为你无论如何都不会像在真正的程序中那样编写表达式。x = x++ + ++x
x = x++ + ++x
理解这一点的关键是表达式的值是,它的效果是将 1 加到 (即将值存储在变量中),但这并不意味着在确定值时会发生存储。i++
i
i
i+1
i
在像这样的表达式中,加法的左侧值是“i+1”,右侧的值是“i+1”。i++ + ++i
i
但是,当任何一方的效果发生时,它是不确定的,因此整个表达式的值()是不确定的。i++ + ++i
对于编译者来说,注意到净效应是增加 2 并计算(等于和稍后存储在 中的内容)是完全合理的(并且可能是有效的),或者不这样做。i
i+i+1
i+2
i
你不应该做的是尝试弄清楚你的编译器做了什么,然后发挥它的作用。
对编译器优化设置的更改,显然(对您来说!)对周围代码或编译器的新版本的不相关更改都可能改变行为。
你让自己对在看似不变的代码中突然出现的最耗时的错误类型之一敞开心扉。
编写您需要的代码(例如),并意识到所有现代商业编译器(当优化开启时)都会将其转换为适用于您平台的最有效代码。2*i+1; i+=2;
我什至建议永远不要在任何其他表达中使用,而不是独立使用,然后只是因为它易于阅读。不要以为它在某种程度上比所有现代商业编译器都会为两者发出相同的代码更有效率。他们不是傻瓜。++
i=i+1
评论
(i++)
无论括号如何,计算结果仍为 1i = (i++);
i = (i++);
i++;