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

#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++);

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

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

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

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

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

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

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

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

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

顺序: (5.1.2.3)

给定任意两个评价AB ,如果A是前测序B ,然后执行A应先执行B

未排序:

如果AB之前或之后未排序,则AB不排序。

评估可以是两件事之一:

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

顺序点:

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

现在问这个问题,对于像

int i = 1;
i = i++;

标准说:

6.5 表达式:

如果相对于 对相同标量对象的不同副作用或使用相同标量对象的值进行的值计算, 相对于标量对象的副作用未排序则行为未定义 。 [...]

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

让重命名赋值左边的iil并赋值右边的i++ (在表达式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。

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

因此,继续进行未指定的行为 ,在c99 标准草案6.5节第3段中说( 重点是我 ):

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

所以当我们有这样的一行时:

i = i++ + ++i;

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

由于程序在序列点之间多次修改变量( iu等),因此我们在这里也具有未定义的行为 。从标准草案第6.5条第2款( 重点是我的 ):

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

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

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节中定义为:

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

并指出:

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

回答这个问题的另一种方法,而不是被序列点和未定义行为的奥秘细节所困扰,只是问他们应该意味着什么? 程序员想做什么?

问到的第一个片段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 上花费了无数的时间来讨论诸如此类的表达式以及它们为何未定义的原因。我的两个较长的答案(试图真正解释原因)已存储在网络上:

另请参阅问题 3.8C 常见问题解答列表 第 3 节中的其余问题

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

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 中未定义行为的概念和许多其他变体。

这篇文章: 未定义,未指定和实现定义的行为也很重要。

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

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

而像表达式的语法 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-Werror的最新版本的 GCC 来检测程序中的此类错误,然后 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加 1,得到旧值,然后舍弃该值;然后在逗号运算符处解决副作用;然后将i加 1,结果值成为表达式的值 - 即,这只是写j = (i += 2)一种人为方式,而这又是 “聪明” 的写法

i += 2;
j = i;

然而, ,函数自变量列表不是逗号运算符,且有不同的评价参数之间没有顺序点; 相反,他们的评估彼此之间没有先后顺序;所以函数调用

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

之所以具有未定义的行为 ,是因为函数参数中i++++i的求值之间没有序列点,因此i的值被i++++i两次修改了前一个和下一个序列点之间。

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

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

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

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

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

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