为什么这些构造使用前后递增的未定义行为?


814
#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?
}

12
@Jarett,不,只需要一些指向“序列点”的指针即可。在工作时,我发现一段i = i ++的代码,我想到“这不是在修改i的值”。我测试了,不知道为什么。从那以后,我删除了该陈述,并用i ++代替;
PiX

198
我认为有趣的是,每个人都总是假设这样的问题被提出,因为询问者想使用所讨论的构造。我的第一个假设是PiX知道这些是不好的,但是很好奇为什么在他们使用whataver编译器时会表现出他们的行为……是的,unWind说了什么……它是未定义的,它可以执行任何操作。 ..包括JCF(
跳火

32
我很好奇:为什么编译器似乎不警告诸如“ u = u ++ + ++ u;”这样的结构?结果是否不确定?
2012年

5
(i++)无论括号如何,其结果仍为1
Drew McGowen

2
无论i = (i++);打算做什么,肯定都有更清晰的编写方法。即使定义明确,也是如此。即使在Java中定义了的行为i = (i++);,它仍然是不好的代码。只需写i++;
Keith Thompson

Answers:


566

C具有未定义行为的概念,即某些语言构造在语法上是有效的,但是在运行代码时您无法预测行为。

据我所知,该标准并未明确说明为什么存在未定义行为的概念。在我看来,这仅仅是因为语言设计师希望语义上有一些余地,而不是要求所有实现以完全相同的方式处理整数溢出,这很可能会带来严重的性能损失,他们只是放弃了行为未定义,因此,如果您编写导致整数溢出的代码,则可能会发生任何事情。

因此,考虑到这些,为什么会出现这些“问题”?该语言清楚地指出某些事情会导致不确定的行为。没问题,没有“应该”涉及。如果在声明其中一个涉及的变量时未定义的行为发生了变化volatile,则不会证明或更改任何内容。它是未定义的 ; 您无法对此行为进行推理。

您看起来最有趣的例子是

u = (u++);

是未定义行为的教科书示例(请参阅Wikipedia关于序列点的条目)。


8
@PiX:由于许多可能的原因,事物未定义。其中包括:没有明确的“正确结果”,不同的计算机体系结构将强烈支持不同的结果,现有实践不一致,或者超出了标准范围(例如,有效的文件名)。
理查德

只是让大家感到困惑,现在C11中已经很好地定义了一些这样的示例,例如i = ++i + 1;
MM

2
阅读该标准和已发布的基本原理,很清楚为什么存在UB概念。该标准从未打算完全描述C实现必须适合于任何特定目的的所有操作(请参阅“一个程序”规则的讨论),而是依赖于实现者的判断和产生有用的高质量实现的愿望。适用于低级系统编程的质量实现将需要定义高端数字运算应用程序中不需要的动作的行为。而不是试图使标准变得复杂...
supercat

3
...通过深入探讨哪些极端情况已定义或未定义的极端细节,该标准的作者认识到,实施人员应更好地判断自己期望支持的程序需要哪种行为。超现代主义的编译器假装认为,UB采取某些措施的意图是暗示任何质量程序都不需要它们,但是Standard和论据与这种假定的意图不一致。
超级猫

1
@jrh:我写了这个答案,然后才意识到超现代主义哲学已变得一发不可收拾。令我感到恼火的是从“我们不需要正式识别此行为,因为需要它的平台仍然可以支持它”到“我们可以删除此行为而无需提供可用的替代方法,因为它从未被识别,因此没有任何代码”需要它被打破”。许多行为本应在很久以前就被弃用,以取而代之的是在各个方面都更好的替代方法,但这需要承认其合法性。
超级猫

78

如果您很想知道代码的精确程度,那么只需编译和反汇编您的代码即可。

这是我在机器上得到的,以及我认为正在发生的事情:

$ 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指令是某种编译器优化?)


我如何获得机器代码?我使用Dev C ++,并且在编译器设置中使用“代码生成”选项,但没有多余的文件输出或任何控制台输出
bad_keypoints 2012年

5
@ronnieaka gcc evil.c -c -o evil.bingdb evil.bindisassemble evil,或Windows等效的其他任何方法:)
badp 2012年

21
这个答案并没有真正解决的问题Why are these constructs undefined behavior?
Shafik Yaghmour 2014年

9
顺便说一句,编译(使用gcc -S evil.c)会更容易,这就是这里所需要的。组装然后拆卸它只是一种绕行方式。
2015年

50
作为记录,如果出于某种原因您想知道给定构造体的功能- 尤其是如果怀疑它可能是未定义的行为,那么“只需尝试使用编译器并查看”的古老建议就是可能非常危险。今天,在这种情况下,您最多将学到在此编译器版本下的功能。如果可以保证您做的任何事情,您都不会学到很多。通常,“只要在您的编译器上尝试一下”就会导致仅适用于您的编译器的不可移植程序。
史蒂夫·萨米特

64

我认为C99标准的相关部分是6.5表达式,§2

在上一个序列点与下一个序列点之间,对象的存储值最多只能通过对表达式的求值来修改。此外,先验值应仅被读取以确定要存储的值。

和6.5.16赋值运算符,第4节:

未指定操作数的评估顺序。如果试图修改赋值运算符的结果或在下一个序列点之后访问它,则该行为是不确定的。


2
以上是否暗示'i = i = 5;“将是未定义的行为?
supercat

1
据我所知i=i=5
@ supercat

2
@Zaibis:我想在大多数地方使用的原理规则适用于理论上,多处理器平台可以实现诸如A=B=5;“写锁A;写锁B;将5存储到A;将5存储到B;解锁B”之类的东西;“解锁A;”,以及类似C=A+B;“读取锁定A;读取锁定B;计算A + B;解锁A和B;写入锁定C;存储结果;解锁C;” 这样的语句。这样可以确保,如果一个线程执行了,A=B=5;而另一个线程执行了,则后一个线程C=A+B;要么将两次写入都视为已发生,要么都不执行。潜在的有用保证。I=I=5;但是,如果有一个线程,...
supercat 2013年

1
...并且编译器没有注意到两次写入都在同一位置(如果一个或两个左值涉及指针,可能很难确定),则生成的代码可能会死锁。我认为,任何现实世界中的实现都不会将这种锁定作为其正常行为的一部分来实现,但是根据标准,它是允许的,并且如果硬件可以廉价地实现这种行为,则可能会很有用。在当今的硬件上,这种行为默认实现起来太昂贵了,但这并不意味着总是如此。
2013年

1
@supercat,但是仅凭c99的顺序点访问规则不足以将其声明为未定义行为吗?因此,硬件可以实现什么技术都没有关系?
dhein 2013年

55

这里大多数引用C标准的答案都强调这些构造的行为是不确定的。要了解为什么未定义这些构造的行为,让我们首先根据C11标准来理解这些术语:

顺序:(5.1.2.3)

鉴于任何两个评估AB,如果A之前被测序B,然后执行A应先执行B

未排序:

如果A未在之前或之后进行排序B,则A和是未排序的B

评估可以是两件事之一:

  • 值计算计算出表达式的结果;和
  • 副作用,是对对象的修改。

顺序点:

一序列点的表达式的计算之间存在AB意味着每个值计算副作用与相关联的A每一个之前进行测序值计算副作用相关联B

现在问这个问题,对于像

int i = 1;
i = i++;

标准说:

6.5表达式:

如果一个标量对象上的副作用是相对于未测序任一相同的标量对象上的不同的副作用,或使用相同的标量对象的值的值的计算,该行为是未定义。[...]

因此,上面的表达式调用UB,因为对同一对象的两个副作用i相对于彼此没有顺序。这就是说,分配给的副作用i是在的副作用之前还是之后进行的,都没有先后顺序++
根据赋值是在增量之前还是之后进行,将产生不同的结果,这就是不确定行为的一种

让重命名i赋值左边的be 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  

产生两个不同的结果12并且取决于赋值的副作用序列,++因此调用UB。


52

该行为无法真正解释,因为它同时调用了未指定的行为未定义的行为,因此我们无法对此代码做出任何一般性的预测,尽管如果您阅读Olve Maudal的著作(例如Deep CUnspecified and Undefined),有时可以在特定情况下使用特定的编译器和环境进行猜测,但是请不要在生产环境附近进行任何操作。

因此,在继续不确定的行为,在草案C99标准6.53说(重点煤矿):

语法表示操作符和操作数的分组。74)除非稍后指定(对于函数调用(),&&,||,?:和逗号运算符),子表达式的求值顺序和哪种副作用都未指定。

因此,当我们有这样的一行时:

i = i++ + ++i;

我们不知道是否i++++i将首先评估。这主要是为编译器提供更好的优化选项

我们也有不确定的行为在这里也因为程序修改变量(iu,等)超过之间一旦序列点。从标准草案6.52节草案(重点是我的):

在上一个序列点与下一个序列点之间,对象的存储值最多只能通过对表达式的求值来修改。此外,在先值仅应被读取以确定要存储的值

它引用了以下未定义的代码示例:

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为:

在使用非便携式或错误程序构造或错误数据时的行为,对此国际标准不施加任何要求

并指出:

可能的不确定行为的范围从完全忽略具有不可预知结果的情况到在翻译或程序执行过程中以环境特征的书面方式表现的行为(带有或不带有诊断消息)到终止翻译或执行(带有发布)诊断消息)。


33

回答这个问题的另一种方法,不是迷住序列点和未定义行为的奥秘细节,而是简单地问,它们应该是什么意思? 程序员想做什么?

i = i++ + ++i清楚的第一个片段在我的书中显然很疯狂。没有人会在一个真实的程序中编写它,它的作用并不明显,没有一种可以想象的算法,有人试图编写会导致这种特定的人为操作序列的算法。而且由于您和我都不知道应该做什么,因此如果编译器也无法弄清楚应该做什么,那么在我的书中也可以。

第二个片段i = i++稍微容易理解。显然有人在尝试增加i,并将结果分配回i。但是用C语言有两种方法。在几乎所有编程语言中,将1加到i并将结果分配回i的最基本方法是相同的:

i = i + 1

C当然有一个方便的快捷方式:

i++

这意味着,“将1加到i,然后将结果分配回i”。因此,如果我们通过写来构建两者的大杂烩

i = i++

我们真正在说的是“将1加到i,然后将结果分配回i,然后将结果分配回i”。我们很困惑,所以如果编译器也很困惑,那也不会太困扰我。

实际上,只有在人们将它们用作++应该如何工作的人工示例时,这些疯狂的表达式才被编写出来。当然,了解++的工作原理也很重要。但是,使用++的一条实用规则是:“如果不清楚使用++的表达式意味着什么,请不要编写它。”

过去,我们在comp.lang.c上花费了无数的时间来讨论诸如此类的表达式以及它们为何未定义的原因。我的两个较长的答案(试图真正解释原因)已存储在Web上:

另请参见问题3.8和在剩余的问题第3节中的Ç常见问题列表


1
对于不确定的行为相当恶劣的疑难杂症是,虽然它使用的是安全上的编译器99.9%使用*p=(*q)++;的意思if (p!=q) *p=(*q)++; else *p= __ARBITRARY_VALUE;是不再是这种情况。超现代C将需要编写类似于后一种公式的内容(尽管没有标准的方法表明代码不在乎*p),以达到编译器用来提供前者的效率水平(该else子句对于使编译器会优化if一些较新的编译器所需的内容)。
超级猫

@supercat我现在认为,任何足够“聪明”地执行那种优化的编译器也必须足够聪明地窥视assert语句,以便程序员可以在前面的一行中添加一个简单的assert(p != q)。(当然,采取这种做法还需要重写<assert.h>,以免在非调试版本中彻底删除断言,而应将其转换__builtin_assert_disabled()为编译器可以看到的类似内容,然后再不为其发出代码。)
Steve Summit

25

通常,这个问题被链接为与以下代码相关的问题的副本

printf("%d %d\n", i, i++);

要么

printf("%d %d\n", ++i, i++);

或类似的变体。

如上所述,虽然这也是未定义的行为,但是printf()与以下语句进行比较时所涉及的细微差别:

x = i++ + i++;

在以下语句中:

printf("%d %d\n", ++i, i++);

评价的顺序的论据printf()不确定。这意味着,表达式i++++i可能以任意顺序进行评估。C11标准对此有一些相关描述:

附件J,未指明的行为

在函数调用中评估函数指示符,参数和参数中的子表达式的顺序(6.5.2.2)。

3.4.4,未指定的行为

使用未指定的值,或在本国际标准提供两种或两种以上可能性的情况下使用其他行为,并且在任何情况下均不对所选内容施加任何其他要求。

示例未指定行为的示例是对函数的参数进行评估的顺序。

不确定的行为本身是不是一个问题。考虑以下示例:

printf("%d %d\n", ++x, y++);

这也有明确的行为,因为计算的顺序++xy++是不确定的。但这是完全合法有效的声明。有没有在这个声明中未定义的行为。因为修改(++xy++)是针对不同的对象完成的。

是什么导致以下陈述

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递增i6i++产生i6)的旧值,该值分配给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));似乎具有稳定的行为(在gcc v7.3.0中从右至左参数评估;结果“ a = 110 b = 40 c = 60”)。是因为分配被认为是“完整陈述”并因此引入了顺序点吗?那不应该导致从左到右的论证/陈述评估吗?还是仅仅是未定义行为的体现?
kavadias '18 -10-17

@kavadias出于与上述原因相同的原因,该printf语句涉及未定义的行为。您分别在第3个和第4个自变量中编写bc在第2个自变量中进行读取。但是这些表达式之间没有顺序(第二,第三和第四参数)。gcc / clang也有一个选项-Wsequence-point可以帮助找到这些。
PP

23

尽管不太可能有任何编译器和处理器实际这样做,但是在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增加2),并且将来的处理器可能会提供类似的功能并不是完全不可想象的。

如果编译器要i++按照上述指示进行编写(在标准下为合法),并且在整个表达式的评估过程中散布以上指令(也是合法的),并且没有注意到另一条指令已发生进行访问时i,编译器有可能(且合法)生成一系列死锁的指令。可以肯定的i是,在两个地方都使用相同变量的情况下,但是如果例程接受对两个指针p和的引用q(*p)(*q)在上述表达式中使用和(而不是使用i两次),则不需要编译器识别或避免如果两个对象都传递了相同的对象地址,则会发生死锁。pq


16

虽然语法表达的喜欢a = a++或者a++ + a++是合法的,该行为这些结构是不确定的,因为在C标准不服从。C99 6.5p2

  1. 在上一个序列点与下一个序列点之间,对象的存储值最多只能通过对表达式的求值来修改。[72]此外,在先值仅应被读取以确定要存储的值[73]

脚注73进一步澄清

  1. 本段呈现未定义的语句表达式,例如

    i = ++i + 1;
    a[i++] = i;

    同时允许

    i = i + 1;
    a[i] = i;

C11(和C99)的附件C中列出了各个序列点:

  1. 以下是5.1.2.3中描述的顺序点:

    • 在函数指定符的评估与函数调用与实际调用中的实际参数之间。(6.5.2.2)。
    • 在以下运算符的第一和第二操作数的求值之间:逻辑AND &&(6.5.13); 逻辑或|| (6.5.14);逗号(6.5.17)。
    • 在条件运算符第一个操作数的求值之间 :运算符以及第二和第三操作数中的任何一个都将被评估(6.5.15)。
    • 完整声明符的结尾:声明符(6.7.6);
    • 在评估一个完整表达式与要评估的下一个完整表达式之间。以下是完整的表达式:不属于复合文字(6.7.9)的初始化程序;表达式语句中的表达式(6.8.3);选择语句(如果或开关)的控制表达式(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中一段的措词为:

  1. 如果相对于相同标量对象的不同副作用或使用相同标量对象的值进行的值计算,相对于标量对象的副作用未排序,则该行为不确定。如果一个表达式的子表达式有多个允许的排序,则如果在任何排序中都发生这种无序的副作用,则该行为是不确定的。84)

例如,您可以使用带有-Wall和的最新版本的GCC来检测程序中的此类错误-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一,结果值成为表达式的值-即,这只是一种人为的编写方式,j = (i += 2)而这又是一种“聪明”的编写方式

i += 2;
j = i;

但是,,函数中的参数列表不是逗号运算符,并且不同参数的求值之间没有顺序点。取而代之的是,他们彼此之间的评估是无序的;所以函数调用

int i = 0;
printf("%d %d\n", i++, ++i, i);

具有不确定的行为,因为有的评估之间没有顺序点i++++i在函数参数,和的值i因此被修改两次,由两个i++++i,先前的和下一个序列点之间。


14

C标准说,一个变量最多只能在两个序列点之间分配一次。例如,分号是一个序列点。
因此,每个形式的语句:

i = i++;
i = i++ + ++i;

以此类推。该标准还规定行为是不确定的,不是不确定的。一些编译器确实会检测到这些并产生一些结果,但这不是每个标准的结果。

但是,可以在两个序列点之间增加两个不同的变量。

while(*src++ = *dst++);

上面是复制/分析字符串时的常见编码实践。


当然,它不适用于一个表达式中的不同变量。如果这样做的话,那将是一次彻底的设计失败!在第二个示例中,您所需要的只是在语句结束和下一个语句开始之间都进行递增,这是有保证的,正是由于序列点位于所有这些语句的中心。
underscore_d

11

/programming/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增量不计算其余前保证所有的完成。实际上,不同的编译器在这里会得到不同的结果。在您所提供的示例中,第一2 ++i执行,则值k[]被读取,然后最后++ik[]

num = k[i+1]+k[i+2] + k[i+3];
i += 3

现代编译器将对此进行很好的优化。实际上,它可能比您最初编写的代码更好(假设它按照您希望的方式工作)。


5

关于这种计算会发生什么好解释的文件提供n1188该ISO W14网站

我解释一下这些想法。

适用于这种情况的标准ISO 9899的主要规则是6.5p2。

在上一个序列点与下一个序列点之间,对象的存储值最多只能通过对表达式的求值来修改。此外,先验值应仅被读取以确定要存储的值。

序列指向类似 i=i++ before i=和after之后i++

在上面引用的论文中,我们解释了您可以认为程序是由小盒子组成的,每个小盒子包含2个连续序列点之间的指令。在以下情况下,序列点在标准的附录C中定义:i=i++有2个序列点界定完整表达。这种表达在语法上等同expression-statement于Backus-Naur语法形式的条目(该语法在标准的附件A中提供)。

因此,框内的指令顺序没有明确的顺序。

i=i++

可以解释为

tmp = i
i=i+1
i = tmp

或作为

tmp = i
i = tmp
i=i+1

因为所有这些形式都可以解释代码 i=i++都是有效的,并且都产生不同的答案,所以行为是不确定的。

因此,在组成程序的每个框的开头和结尾都可以看到一个顺序点(框是C中的原子单位),框内的指令顺序并非在所有情况下都定义。改变那个顺序有时可以改变结果。

编辑:

其他解释此类歧义的好方法是来自c-faq网站(也作为书出版)的条目,即此处此处此处


这个答案如何在现有答案中添加新的?对此的解释i=i++也很相似。
haccks

@haccks我没有看其他答案。我想用自己的语言解释从ISO 9899官方网站open-std.org/jtc1/sc22/wg14/www/docs/n1188.pdf
alinsoar

5

您的问题可能不是,“为什么这些构造在C中未定义行为?”。您的问题可能是“为什么此代码(使用++)没有给我期望的值?”,有人将您的问题标记为重复,然后将您发送给这里。

这个答案试图回答这个问题:为什么您的代码没有给您期望的答案,以及如何学习识别(并避免)不能按预期工作的表达式。

我想您现在已经听说了C ++--运算符的基本定义,以及前缀形式++x与postfix形式有何不同x++。但是这些运算符很难考虑,因此为确保您理解,也许您编写了一个包含以下内容的小型测试程序:

int x = 5;
printf("%d %d %d\n", x, ++x, x++);

但是,令您惊讶的是,该程序并没有帮助您理解-它打印了一些奇怪的,意想不到的,莫名其妙的输出,表明也许++所做的事情完全不同,而根本没有您认为的那样。

或者,也许您正在看一个难以理解的表达式,例如

int x = 5;
x = x++ + ++x;
printf("%d\n", x);

也许有人给您该代码令人困惑。该代码也没有意义,尤其是如果您运行它-并且如果在两个不同的编译器下编译并运行它,则可能会得到两个不同的答案!那是怎么回事?哪个答案正确?(答案是他们两个都是,或者两个都不是。)

正如您现在所听到的,所有这些表达式都是undefined,这意味着C语言不能保证它们会做什么。这是一个奇怪且令人惊讶的结果,因为您可能认为只要编写并运行该程序,任何可以编写的程序都会生成唯一的,定义明确的输出。但是对于未定义的行为,事实并非如此。

是什么使表达式不确定?表达式是否涉及++并且--始终未定义?当然不是:它们是有用的运算符,如果正确使用它们,它们的定义就很好。

对于表达式,我们要说的是使它们变得不确定的原因是:一次执行的事情太多,何时我们不确定事物将以什么顺序发生,但是何时顺序对结果产生影响。

让我们回到这个答案中使用的两个例子。当我写

printf("%d %d %d\n", x, ++x, x++);

问题是,在调用之前printf,编译器是否会计算xfirst或x++or或也许的值++x?但是事实证明我们不知道。C中没有规则说函数的参数从左到右或从右到左或以其他顺序求值。所以,我们不能说编译器是否会做x第一个,然后++x,然后x++,或x++++x然后x,或其他一些订单。但是顺序显然很重要,因为根据编译器使用的顺序,我们显然会得到印刷的不同结果printf

那疯狂的表情呢?

x = x++ + ++x;

该表达式的问题在于,它包含三种不同的尝试来修改x的值:(1)该x++部分尝试向x加1,将新值存储在x中x,然后返回x 的旧值x;(2)该++x部分尝试将x加1,将新值存储在中x,然后返回的新值x;(3)该x =部分尝试将其他两个的和分配回x。这三个尝试中的哪一个将“获胜”?这三个值中的哪一个实际上将被分配给x?同样,也许令人惊讶的是,C语言中没有规则可以告诉我们。

您可能会想到,优先级,关联性或从左至右的评估告诉您事情发生的顺序,但事实却并非如此。您可能不相信我,但是请您信守我的诺言,我再说一遍:优先级和关联性并不能确定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. 如果有一个变量在两个或多个不同的位置被修改(分配给),您如何知道哪个修改首先发生?
  2. 如果某个变量在一个地方被修改,而其值在另一个地方被使用,您如何知道它使用的是旧值还是新值?

以#1为例,在表达式中

x = x++ + ++x;

有三种修改`x的尝试。

例如,在表达式2中

y = x + x++;

我们都使用的值x,并对其进行修改。

答案就是这样:确保在您编写的任何表达式中,每个变量最多被修改一次,并且如果一个变量被修改,您也不要尝试在其他地方使用该变量的值。


3

原因是程序正在运行未定义的行为。问题在于求值顺序,因为根据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),然后将值赋给左手(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文档在评估优先级和评估顺序中所述

当多个运算符一起出现时,它们具有相同的优先级,并根据其关联性进行评估。表中的运算符在以Postfix运算符开头的部分中进行了描述。


1
我已经编辑了该问题以将UB添加到函数参数的评估中,因为该问题通常被用作该问题的重复项。(最后一个示例)
Antti Haapala

1
另外,现在的问题是关于c的,而不是c ++的问题
Antti Haapala 17-10-21
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.