为什么这些构造(使用++)未定义的行为?
#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 %d\n", w++, ++w, w); // shouldn't this print 0 2 2 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具有未定义行为的概念,即某些语言结构在语法上是有效的,但不能预测代码运行时的行为。
据我所知,标准没有明确说明为什么存在未定义行为的概念。 在我看来,这只是因为语言devise师希望在语义上有一些余地,而不是要求所有实现都以完全相同的方式处理整数溢出,这很可能会带来严重的性能成本,他们只是留下行为未定义,所以如果你写代码导致整数溢出,任何事情都可能发生。
那么,为什么这些“问题”呢? 该语言清楚地表明,某些事情会导致未定义的行为 。 没有问题,没有“应该”涉及。 如果未定义的行为在其中一个涉及的variables被声明为volatile
时发生改变,那么这个行为就不能certificate或改变任何东西。 它是不确定的 ; 你不能推断行为。
你最有趣的例子,与
u = (u++);
是未定义行为的文本书籍示例(请参阅维基百科关于顺序点的条目)。
只是编译和反汇编你的代码行,如果你很想知道你到底得到了什么。
这就是我在我的机器上得到的结果,以及我认为正在发生的事情:
$ 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 = ii = 1 j = 1 0x00000014 <+20>: add %eax,%eax // j += ji = 1 j = 2 0x00000016 <+22>: add %eax,-0x4(%ebp) // i += ji = 3 0x00000019 <+25>: addl $0x1,-0x4(%ebp) // i++ i = 4 0x0000001d <+29>: leave 0x0000001e <+30>: ret End of assembler dump.
(我…假设0x00000014指令是某种编译器优化?)
我认为C99标准的相关部分是6.5expression式,§2
在前一个序列点和下一个序列点之间,一个对象应该通过评估一个expression式来最多修改其存储值一次。 此外,先前的值应该是只读的,以确定要存储的值。
和6.5.16作业操作员,§4:
操作数的评估顺序是未指定的。 如果尝试修改赋值运算符的结果或在下一个序列点之后访问它,则行为是不确定的。
这个行为不能被真正解释,因为它调用了未指定的行为和未定义的行为 ,所以我们不能对这个代码做任何一般的预测,但是如果你读了Olve Maudal的工作,比如Deep C和Unspecified和Undefined,在特定的情况下用特定的编译器和环境猜测,但请不要在接近生产的任何地方进行。
所以在不明确的行为上 ,在c99标准草案第6.5
节第3段中说( 重点是我的 ):
除了后面的规定(对于函数调用(),&&,||,?:和逗号运算符), 子expression式的评估顺序和哪些副作用发生都没有说明。
所以当我们有这样一行时:
i = i++ + ++i;
我们不知道i++
是否会先评估i++
或++i
。 这主要是为了给编译器提供更好的优化选项 。
我们在这里也有未定义的行为 ,因为程序在序列点之间不止一次地修改variables( i
, u
等等)。 从标准草案第6.5
条第2款( 重点矿 ):
在前一个序列点和下一个序列点之间,一个对象应该通过评估一个expression式来最多修改其存储值一次 。 此外, 先前的值应该是只读的,以确定要存储的值 。
它引用下面的代码示例为undefined:
i = ++i + 1; a[i++] = i;
在所有这些例子中,代码试图在相同的序列点不止一次地修改一个对象;
在每一种情况下:
i = i++ + ++i; ^ ^ ^ i = (i++); ^ ^ u = u++ + ++u; ^ ^ ^ u = (u++); ^ ^ v = v++ + ++v; ^ ^ ^
未规定的行为在3.4.4
节的c99标准草案中定义为:
使用不明确的价值,或者本国际标准提供两种或两种以上可能性的情况下的其他行为,并且在任何情况下都没有提出进一步的要求
而未定义的行为在第3.4.3
节定义为:
行为,在使用不可移植或错误的程序结构或错误的数据时,本国际标准对此没有要求
并指出:
可能的未定义的行为范围包括从完全忽略情况和不可预测的结果,到在翻译或程序执行过程中以环境特征(有或没有发出诊断消息),终止翻译或执行(在发行的诊断消息)。
这里的大部分答案都是从C标准引用的,强调这些构造的行为是不确定的。 要理解为什么这些构造的行为是不确定的 ,首先根据C11标准来理解这些术语:
测序: (5.1.2.3)
给定任何两个评估
A
和B
,如果A
在B
之前被sorting,那么A
的执行应该在B
的执行之前。
未测序:
如果
A
在B
之前或之后未被测序,则A
和B
不被测序。
评估可以是以下两件事之一:
- 计算expression式的结果; 和
- 副作用 ,这是对象的修改。
序列点:
expression式
A
和B
的评估之间的序列点的存在意味着A
与B
有关的每个值计算和副作用之前,与A
有关的每个值计算和副作用被sorting。
现在来回答这个问题,对于像
int i = 1; i = i++;
标准说:
6.5expression式:
如果对标量对象的 副作用不是相对于 同一个标量对象的不同副作用或使用相同标量对象的值进行值计算而言是不确定的 , 则行为是未定义的 。 […]
因此,上面的expression式调用UB是因为对同一个对象i
两个副作用是相互不相关的。 这意味着它没有sorting是否由副作用分配给i
会在++
之前或之后的副作用。
根据赋值是在增量之前还是之后发生,会产生不同的结果,这是未定义行为的情况之一。
让我们在赋值左边重命名为i
,并且在赋值(在expression式i++
)的右边是ir
,然后expression式就像
il = ir++ // Note that suffix l and r are used for the sake of clarity. // Both il and ir represents the same object.
关于Postfix ++
操作符的一个重要的点是:
只是因为variables之后的
++
并不意味着增量发生得太晚 。 只要编译器确保使用原始值,就可以在编译器喜欢的时候增加。
这意味着expression式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
导致两个不同的结果1
和2
,这取决于通过赋值和++
副作用的顺序,因此调用UB。
回答这个问题的另一种方法是,不要陷入序列点和未定义行为的神秘细节中,而只是在问, 它们应该是什么意思? 程序员试图做什么?
第一个片段问i = i++ + ++i
, i = i++ + ++i
,在我的书中显然很疯狂。 没有人会把它写在一个真正的程序中,它不是什么明显的东西,没有可以想象的algorithm,有人可能会试图编码,这将导致这个特定的人为操作序列。 而且,由于对于我们来说,这并不是很明显,所以如果编译器无法弄清楚它应该做什么,那么在我的书中就可以了。
第二个片段, i = i++
,有点容易理解。 有人显然试图增加我,并将结果分配给我。 但是在C中有几种方法可以做到这一点。向i添加1并将结果赋给i的最基本的方法在几乎所有的编程语言中都是一样的:
i = i + 1
C当然有一个方便的捷径:
i++
这意味着,“给我加1,并把结果分配给我”。 所以如果我们通过写作来构build这两者的大杂烩
i = i++
我们真正说的是“给我加1,然后把结果赋给i,并把结果赋给i”。 我们感到困惑,所以如果编译器也感到困惑的话,也不会太麻烦。
实际上,这些疯狂的expression式唯一被写入的时候是人们用它们作为++如何工作的人为的例子。 当然了解++如何工作也很重要。 但是使用++的一个实际规则是,“如果用++表示什么是不明显的,就不要写它”。
我们曾经在comp.lang.c上花费无数时间来讨论像这样的expression式以及为什么它们是未定义的。 我的两个较长的答案,试图真正解释为什么,在网上http://www.eskimo.com/~scs/readings/undef.950321.html和http://www.eskimo.com/~ scs / readings / precvsooe.960725.html 。
虽然任何编译器和处理器实际上都不可能这样做,但在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
虽然我不认为任何处理器都支持硬件来让这样的事情有效地完成,但是我们可以很容易地想象出这种行为会使multithreading代码更容易的情况(例如,如果两个线程试图执行上述操作序列同时, i
会得到两个增加),并不是完全不可思议的是,一些未来的处理器可能会提供这样的function。
如果编译器如上所述编写i++
(在标准下是合法的),并且在整个expression式(也是合法的)的评估过程中散布上述指令,并且如果它没有注意到其他指令之一碰巧访问i
,编译器将有可能(和合法)产生一个死锁的指令序列。 可以肯定的是,编译器在两个地方使用相同variablesi
的情况下几乎肯定会检测到问题,但是如果例程接受对两个指针p
和q
引用,并使用(*p)
和(*q)
在上面的expression式中(而不是使用两次),编译器将不需要识别或避免在p
和q
同时传递相同对象的地址时发生的死锁。
通常这个问题被链接成与代码相关的问题的重复
printf("%d %d\n", i, i++);
要么
printf("%d %d\n", ++i, i++);
或类似的变体。
虽然这也是一个未定义的行为 ,但是在与以下语句进行比较时涉及到printf()
时会有细微的差别:
x = i++ + i++;
在以下声明中:
printf("%d %d\n", ++i, i++);
printf()
中参数的评估顺序是未指定的 。 这意味着, ++i
可以以任何顺序评估expression式i++
和++i
。 C11标准对此有一些相关描述:
附件J,未指明的行为
(6.5.2.2)中函数指示符,参数和子expression式的顺序在函数调用中被评估。
3.4.4,不明确的行为
使用不明确的价值或其他本国际标准提供两种或两种以上可能性的行为,并且在任何情况下都没有进一步的要求。
示例未指定行为的示例是函数参数的评估顺序。
未指定的行为本身不是问题。 考虑这个例子:
printf("%d %d\n", ++x, y++);
由于++x
和y++
的评估顺序未指定,因此也有未指定的行为 。 但这是完全合法和有效的陈述。 本声明中没有未定义的行为。 因为修改( ++x
和y++
)完成不同的对象。
什么呈现以下声明
printf("%d %d\n", ++i, i++);
因为未定义的行为是这样的事实,即这两个expression式修改相同的对象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=%dj=%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中未定义行为的概念和许多其他变体。
这篇文章: 未定义,未指定和实现定义的行为也是相关的。
C标准规定一个variables最多只能在两个序列点之间分配一次。 分号例如是一个序列点。
所以每一种forms的陈述:
i = i++; i = i++ + ++i;
如此违反该规定。 该标准还表示,行为是不确定的,而不是没有说明的。 一些编译器检测到这些并产生一些结果,但这不是每个标准。
但是,两个不同的variables可以在两个序列点之间递增。
while(*src++ = *dst++);
以上是复制/分析string时的常见编码习惯。
在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。
++i
增量不能保证在其余的计算之前全部完成。 实际上,不同的编译器在这里会得到不同的结果。 在你提供的例子中, ++i
执行了第一个2 ++i
,然后读取了k[]
的值,然后是最后一个++i
然后k[]
。
num = k[i+1]+k[i+2] + k[i+3]; i += 3
现代编译器将优化这个很好。 事实上,可能比你最初编写的代码更好(假设它已经按照你所希望的方式工作)。
虽然C的语法允许这样的构造,但程序的行为是不确定的,因为C标准中的一个不被遵守。 C99 6.5p2 :
在前一个序列点和下一个序列点之间,一个对象应该通过评估一个expression式来最多修改其存储值一次。 此外,先前值应该是只读的,以确定要存储的值[73]
脚注73进一步澄清了这一点
这段描述了未定义的语句expression式,如
i = ++i + 1; a[i++] = i;
同时允许
i = i + 1; a[i] = i;
你可以在一个程序中检测到这样的错误,例如使用最近版本的GCC和-Wall
和-Werror
,然后GCC将彻底拒绝编译你的程序。 以下是gcc的输出(Ubuntu 6.2.0-5ubuntu12)6.2.0 20161005:
% 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);
是明确的,并且会使i
增加一个旧的价值,丢弃这个价值; 然后在逗号运营商,解决副作用; 然后把i
加1,结果得到的值就是expression式的值 – 也就是写出j = (i += 2)
,这又是一个“聪明”的写法
i += 2; j = i;
但是函数参数中没有逗号运算符, 因此,在function论证的评价之间不存在序列点 – 它们彼此没有关系; 所以函数调用
int i = 0; printf("%d %d\n", i++, ++i, i);
具有未定义的行为,因为在函数参数中 , i++
和++i
的评估之间没有序列点 ,而i
用来读取i
的值,而不是确定要存储在 i
的值。
原因是程序运行未定义的行为。 问题在于评估顺序,因为根据C ++ 98标准没有要求的顺序点(根据C ++ 11术语没有任何操作按顺序排列)。
但是,如果你坚持一个编译器,只要不添加函数调用或指针,就会发现行为是持久的,这会使行为更加混乱。
-
所以首先GCC:使用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)评估子expression式,然后将值分配到左侧(LHS)。 这正是Java和C#的行为和定义他们的标准。 (是的,Java和C#中的等效软件已经定义了行为)。 它在RHS声明中按照从左到右的顺序逐一评估每个子expression式; 对于每个子expression式:++ c(pre-increment)首先被计算,然后c被用于操作,然后是后增量c ++)。
根据GCC C ++:操作符
在GCC C ++中,运算符的优先级控制着单个运算符的评估顺序
定义的行为C ++中的等价代码就像GCC所理解的那样:
#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是如何工作的,它采用另一种方法,在第一遍中评估所有的预增量expression式,然后在第二遍中使用variables值,在第三遍中从RHS分配给LHS,最后通过评估所有的在一次传递后增量expression式。
因此,在C ++中定义的行为与Visual 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文档在优先级和评估顺序上声明 :
当几个操作员一起出现时,他们具有相同的优先级,并根据其关联性进行评估。 表中的操作符在Postfix Operators开始的章节中描述。
ISO W14站点的文档n1188中提供了有关此类计算中发生的情况的一个很好的解释。
我解释这些想法。
适用于这种情况的标准ISO 9899的主要规则是6.5p2。
在前一个序列点和下一个序列点之间,一个对象应该通过评估一个expression式来最多修改其存储值。 此外,先前的值应该是只读的,以确定要存储的值。
像i=i++
这样的expression式中的序列点在i=i++
之前和i++
。
在我上面引用的文章中解释说,你可以把程序看成是由小方框组成的,每个方框包含2个连续顺序点之间的指令。 序列点在标准的附录C中定义,在i=i++
的情况下,有2个序列点定界了一个完整expression式。 Such an expression is syntactically equivalent with an entry of expression-statement
in the Backus-Naur form of the grammar (a grammar is provided in annex A of the Standard).
So the order of instructions inside a box has no clear order.
i=i++
can be interpreted as
tmp = i i=i+1 i = tmp
或如
tmp = i i = tmp i=i+1
because both all these forms to interpret the code i=i++
are valid and because both generate different answers, the behavior is undefined.
So a sequence point can be seen by the beginning and the end of each box that composes the program [the boxes are atomic units in C] and inside a box the order of instructions is not defined in all cases. Changing that order one can change the result sometimes.
编辑:
Other good source for explaining such ambiguities are the entries from c-faq site (also published as a book ) , namely here and here and here .