提问



#include 

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具有未定义行为的概念,即某些语言结构在语法上有效,但您无法预测代码运行时的行为。


据我所知,标准并没有明确说明为什么存在未定义行为的概念。在我看来,这只是因为语言设计者希望在语义上有一些余地,而不是要求所有实现以完全相同的方式处理整数溢出,这很可能会造成严重的性能成本,他们只是将行为保留为未定义,这样如果编写导致整数溢出的代码,任何事情都可能发生。


那么,考虑到这一点,为什么这些问题?该语言清楚地表明,某些事情会导致不确定的行为。没有问题,没有应该参与。如果未声明的行为在声明其中一个涉及的变量volatile时发生变化,那么它不会证明或改变任何东西。它是未定义的;你无法推断这种行为。[133]]]


你最有趣的例子,有一个


u = (u++);


是未定义行为的教科书示例(请参阅维基百科关于序列点的条目)。[134]

其它参考1


只需编译和反汇编您的代码行,如果您倾向于知道它是如何得到您正在获得的。


这就是我在我的机器上得到的,以及我的想法:


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

其它参考2


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



??在前一个和下一个序列点之间,对象应具有其存储值
??通过表达式的评估最多修改一次。此外,先前的价值
??应只读以确定要存储的值。



和6.5.16分配运算符,§4:



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


其它参考3


行为无法解释,因为它会调用未指定的行为和未定义的行为,因此我们无法对此代码进行任何一般性预测,尽管如果您阅读 Olve Maudal的工作,例如Deep C有时你可以用特定的编译器和环境在非常具体的情况下做出很好的猜测,但是请不要在生产附近做任何事情。[135] [136] [137] [138]]]


所以继续未指明的行为,在草案c99标准部分6.5段 3 中说(强调我的):[139]]]



??语法指示运算符和操作数的分组。除非另有说明,否则表示操作符和操作数的分组
??稍后(对于函数调用(),&&,||,?:和逗号运算符),子表达式的评估顺序和副作用发生的顺序都是未指定的。



所以,当我们有这样一条线:


i = i++ + ++i;


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


我们这里也有未定义的行为,因为程序在序列点之间不止一次地修改变量(iu等。从草案标准部分6.5段 2 (强调我的):[141]



??在上一个和下一个序列点之间,对象应具有其存储值
??通过表达式的评估,最多修改一次
。此外,先前的值
??应只读以确定要存储的值




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


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


在所有这些示例中,代码尝试在同一序列点中多次修改对象,这将在每种情况下以;结束:


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

i = (i++);
^    ^

u = u++ + ++u;
^   ^       ^

u = (u++);
^    ^

v = v++ + ++v;
^   ^       ^


未指明的行为在3.4.4部分的草案c99标准中定义为:[142]



??使用未指定的值或本国际标准提供的其他行为
??两种或更多种可能性并且没有任何进一步的要求
??例



和未定义的行为在3.4.3节中定义为:



??使用不可移植或错误的程序结构或错误数据时的行为,
??本国际标准没有要求



并注意到:



??可能的未定义行为包括完全忽略不完整结果的情况,在翻译或程序执行期间以环境特征(有或没有发出诊断消息)的特定文档执行,终止翻译或执行(发布时)一条诊断信息)。


其它参考4


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


排序:(5.1.2.3)



??鉴于AB任何两项评估,如果AB之前被排序,那么A的执行应在执行B之前进行。 。



未测序



??如果AB之前或之后没有排序,则AB未被排序。



评估可以是两件事之一:



  • 值计算,它可以计算出表达式的结果;和

  • 副作用,它们是对象的修改。



序列点:



??在表达式AB的评估之间存在序列点意味着与A相关的每个值计算和副作用在与B相关的每个值计算和副作用之前进行排序。



现在提出问题,对于像这样的表达式


int i = 1;
i = i++;


标准说:


6.5表达式:




??如果对标量对象的副作用相对于 对同一个标量对象的不同副作用或使用相同标量对象的值计算值< strong>行为未定义。 [[...]]



因此,上面的表达式调用UB,因为对同一对象i的两个副作用相对于彼此是无序的。这意味着没有对++的副作用是否在++副作用之前或之后进行排序。

根据分配是在增量之前还是之后发生,将产生不同的结果,并且是未定义行为的情况之一。


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

其它参考5


另一种回答这个问题的方法,而不是陷入关于序列点和未定义行为的神秘细节,只是问,它们应该是什么意思? 程序员试图做什么?


第一个片段询问,i = i++ + ++i,在我的书中显然是疯了。没有人会在一个真实的程序中编写它,它的作用并不明显,没有人能想到的算法可能会导致这种特殊的操作序列。而且由于你和我不应该做什么,如果编译器无法弄清楚它应该做什么,那么在我的书中这很好。


第二个片段,i = i++,更容易理解。有人显然试图增加i,并将结果分配给i。但是有几种方法可以在C中执行此操作。向i添加1并将结果返回给i的最基本方法在几乎所有编程语言中都是相同的:


i = i + 1


当然,C有一个方便的捷径:


i++


这意味着,向i添加1,并将结果返回给i。因此,如果我们通过写作构建两者的大杂烩


i = i++


我们真正说的是向i添加1,并将结果返回给i,并将结果返回给i。我们感到困惑,所以如果编译器混淆,它不会让我感到烦恼,太。


实际上,这些疯狂的表达式写作的唯一时间是人们将它们用作++应该如何工作的人为例子。当然,了解++的工作原理非常重要。但是使用++的一个实际规则是,如果使用++意味着什么表达不明显,就不要写它。


我们曾经在comp.lang.c上花了无数个小时来讨论像这样的表达式和为什么它们未定义。我的两个较长的答案,试图真正解释原因,在网上存档:



  • 为什么标准不定义这些做什么?

  • 运营商优先级不确定评估顺序吗?


其它参考6


虽然任何编译器和处理器都不太可能实际这样做,但在C标准下,编译器使用以下序列实现i ++是合法的:[144] [145]


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,但是如果例程接受对两个指针pq的引用,并且在[[...
il = ir++     // Note that suffix l and r are used for the sake of clarity.
              // Both il and ir represents the same object.  
q中使用(*p)(*q)上面的表达式(而不是两次使用i)编译器不需要识别或避免如果为pq传递相同的对象地址时会发生的死锁。

其它参考7


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


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


要么


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


或类似的变种。


虽然这也是已经说明的未定义行为,但在与[[... [146]等语句进行比较时涉及printf()时会有细微差别。


   x = i++ + i++;





在以下声明中:


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


printf()中参数的评估顺序是未指定。这意味着,表达式i++++i可以按任何顺序进行评估。 C11标准对此有一些相关的描述:[147] [148] [149]


附件J,未指明的行为



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



3.4.4,未指明的行为



??使用未指定的值或其他行为
??国际标准提供两种或更多种可能性和强加
??在任何情况下都没有选择进一步的要求。

??
??示例未指定行为的示例是其中的顺序
??计算函数的参数。



未指定的行为本身不是问题。考虑这个例子:


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


这也有未指定的行为,因为++xy++的评估顺序未指定。但它是完全合法且有效的陈述。在此陈述中存在 no 未定义的行为。因为修改(++xy++)是对不同的对象完成的。


是什么呈现以下声明


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


因为未定义的行为是这两个表达式修改相同对象i而没有插入序列点的事实。[150]]]





另一个细节是printf()调用中涉及的逗号是分隔符,而不是逗号运算符。[151]


这是一个重要的区别,因为逗号运算符确实在其操作数的评估之间引入了序列点,这使得以下内容合法:


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++得到i(6的旧值,至j。然后i由于后增量而变为7


因此,如果函数调用中的逗号是逗号运算符,那么


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


不会有问题。但它会调用未定义的行为,因为此处的逗号是分隔符。





对于那些不熟悉未定义行为的人来说,将从阅读每个C程序员应该知道的未定义行为中受益,以理解C中未定义行为的概念和许多其他变体。[152]


这篇文章:未定义,未指定和实现定义的行为也是相关的。

其它参考8


C标准规定变量最多只能在两个序列点之间分配一次。例如,分号是序列点。点击
所以表格的每一个陈述:



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


等违反了这条规则。该标准还表示行为未定义且未指定。有些编译器会检测到这些并产生一些结果,但这不符合标准


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


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


以上是复制/分析字符串时的常见编码习惯。

其它参考9


在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增量在其余计算之前并不能保证全部完成。事实上,不同的编译器在这里会得到不同的结果。在你提供的例子中,前2 ++i执行,然后读取k[]的值,然后是最后++i,然后k[]


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


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

其它参考10


虽然像a = a++a++ + a++这样的表达式的语法是合法的,但这些结构的行为 undefined ,因为不遵守C标准中的 。 C99 6.5p2:[155]



??

    ??
  1. 在前一个和下一个序列点之间,对象的存储值最多只能通过表达式的计算修改一次。 ++此外,先前的值应该是只读的,以确定要存储的值il = ir++

  2. ??



脚注73进一步澄清[156]



??

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


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

    ??
    ??同时允许


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

  2. ??



各种序列点列于C11(和C99)的附件C中:[157] [158]



??

    ??
  1. 以下是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);返回语句中的(可选)表达式(6.8.6.4)。

    • ??
    • 在库函数返回之前(7.1.4)。

    • ??
    • 与每个格式化输入/输出函数转换说明符(7.21.6,7.29.2)相关联的操作之后。

    • ??
    • 在每次调用比较函数之前和之后,以及对比较函数的任何调用和作为参数传递给该调用的对象的任何移动之间(7.22.5)。

    • ??

  2. ??



C11中同一段的措辞是:[159]



??

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

  2. ??






您可以通过例如使用最新版本的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,结果值成为表达式的值 - 也就是说这只是写j = (i += 2)的一种人为的方式,这又是一种聪明的写法


i += 2;
j = i;


但是,函数参数列表中的,是而不是一个逗号运算符,并且在不同参数的计算之间没有序列点;相反,他们相互之间没有相应的顺序;所以函数调用


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


有未定义的行为,因为在函数参数i++++i的评估之间没有序列点,并且i的值因此,[[和i++++i在前一个和后一个之间被修改了两次下一个序列点。

其它参考11


关于这种计算中发生的事情的一个很好的解释在ISO W14网站的文件n1188中提供。[161] [162]


我解释了这些想法。


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



??在前一个和下一个序列点之间,对象的存储值最多只能通过表达式的计算修改一次。此外,先前的值应该是只读的,以确定要存储的值。



i=i++这样的表达式中的序列点在i=之前和i++之后。


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


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


i=i++


可以解释为


tmp = i
i=i+1
i = tmp


或者作为


tmp = i
i = tmp
i=i+1


因为所有这些形式来解释代码i=i++都是有效的,并且因为两者都生成不同的答案,所以行为是未定义的。


因此,组成程序的每个框的开头和结尾都可以看到序列点[[框中是C中的原子单元]],并且在框内,指令的顺序在所有情况下都没有定义。更改该订单有时可以更改结果。


编辑:


解释这种含糊不清的其他好的来源是来自c-faq网站的条目(也作为一本书出版),即此处以及此处和此处。[163] [165] [166] [167]

其它参考12


原因是程序正在运行未定义的行为。问题在于评估顺序,因为根据C ++ 98标准没有所需的序列点(根据C ++ 11术语,没有操作在其他操作之前或之后排序)。


但是,如果你坚持使用一个编译器,你会发现行为是持久的,只要你不添加函数调用或指针,这会使行为更加混乱。



  • 首先是海湾合作委员会:
    使用Nuwen MinGW 15 GCC 7.1,您将获得:[168]


    #include
    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
    


    }



海湾合作委员会如何运作?它以右手边(RHS)从左到右的顺序计算子表达式,然后将值分配给左侧(LHS)。这正是Java和C#的行为和定义标准的方式。 (是的,Java和C#中的等效软件已经定义了行为)。它按照从左到右的顺序在RHS声明中逐个评估每个子表达式;对于每个子表达式:首先计算++ c(预增量),然后将值c用于操作,然后使用后增量c ++)。


根据GCC C ++:运营商[169]



??在GCC C ++中,运算符的优先级控制顺序
??评估各个运营商



GCC理解的C ++定义行为中的等效代码:


#include
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,您得到:[170]


#include
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,然后在最后一遍中它评估所有的一次传递后增量表达式。


所以C ++定义的行为中的等价物与Visual C ++相同:


#include
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文档在优先级和评估顺序中所述:[171]



??在多个运算符一起出现的情况下,它们具有相同的优先级,并根据它们的相关性进行评估。表中的运算符在Postfix Operators开头的部分中描述。