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

Why are these constructs using pre and post-increment undefined behavior?

提问人:PiX 提问时间:6/4/2009 最后编辑:Antti Haapala -- Слава УкраїніPiX 更新时间:10/7/2023 访问量:100228

问:

#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 增量 undefined-behavior 运算符优先级 序列点

评论

16赞 PiX 6/5/2009
@Jarett,不,只需要一些指向“序列点”的指针。在工作时,我发现了一段 i = i++ 的代码,我说“这不是在修改 i 的值”。我测试了一下,我想知道为什么。从那以后,我删除了这个语句,取而代之的是 i++;
231赞 Brian Postow 5/24/2010
我认为有趣的是,每个人都总是假设这样的问题被问到是因为提问者想要使用有问题的结构。我的第一个假设是 PiX 知道这些是坏的,但很好奇为什么它们在他/她正在使用的任何编译器上的行为方式......是的,unWind 说了什么......它是不确定的,它可以做任何事情......包括JCF(跳跃和着火)
40赞 Learn OpenGL ES 9/21/2012
我很好奇:如果结果未定义,为什么编译器似乎不警告诸如“u = u++ + ++u;”之类的结构?
5赞 Drew McGowen 7/19/2013
(i++)无论括号如何,计算结果仍为 1
3赞 Keith Thompson 8/16/2013
无论打算做什么,肯定有一种更清晰的写作方式。即使定义明确,情况也是如此。即使在定义 行为的 Java 中,它仍然是糟糕的代码。只需编写i = (i++);i = (i++);i++;

答:

631赞 unwind 6/4/2009 #1

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

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

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

你看起来最有趣的例子,那个

u = (u++);

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

评论

10赞 Richard 6/4/2009
@PiX:由于多种可能的原因,事物是未定义的。这些包括:没有明确的“正确结果”,不同的机器架构会强烈支持不同的结果,现有做法不一致,或超出标准范围(例如,哪些文件名是有效的)。
6赞 M.M 7/10/2014
只是为了让大家感到困惑,一些这样的例子现在在 C11 中得到了很好的定义,例如 .i = ++i + 1;
4赞 supercat 12/18/2017
阅读标准和已发布的理由,很清楚为什么 UB 的概念存在。该标准从来都不是要完全描述C实现必须做的所有事情,以适应任何特定目的(参见“一个程序”规则的讨论),而是依赖于实现者的判断和产生有用的高质量实现的愿望。适合低级系统编程的高质量实现需要定义在高端数字运算应用程序中不需要的操作行为。与其试图使标准复杂化......
6赞 supercat 12/18/2017
...通过非常详细地了解哪些极端情况被定义或没有被定义,该标准的作者认识到,实施者应该更好地判断他们应该支持的程序需要哪些类型的行为。超现代主义编译者假装做出某些动作 UB 旨在暗示任何质量程序都不需要它们,但标准和基本原理与这种假定的意图不一致。
4赞 supercat 1/4/2018
@jrh:在我意识到超现代主义哲学已经失控之前,我就写下了这个答案。令我恼火的是,从“我们不需要正式承认这种行为,因为需要它的平台无论如何都可以支持它”到“我们可以在不提供可用替代品的情况下删除这种行为,因为它从未被识别过,因此任何需要它的代码都被破坏了”。许多行为早就应该被弃用,取而代之的是各方面都更好的替代品,但这需要承认它们的合法性。
82赞 Christoph 6/4/2009 #2

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

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

和 6.5.16 赋值运算符,§4:

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

评论

2赞 supercat 11/21/2011
以上是否意味着“i=i=5;”将是未定义的行为?
2赞 dhein 9/23/2013
@supercat据我所知,也是未定义的行为i=i=5
3赞 supercat 9/24/2013
@Zaibis:我喜欢在大多数地方使用的基本原理规则适用于理论上多处理器平台可以实现类似“写锁 A;写锁 B;存储 5 到 A;商店 5 到 B;解锁 B;Unock A;“,以及类似”Read-lock A;读锁 B;计算 A+B;解锁 A 和 B;写锁 C;存储结果;解锁 C;“。这将确保如果一个线程执行而另一个线程执行,则后一个线程要么将两个写入都视为已发生,要么两者都没有发生。可能是一个有用的保证。但是,如果一个线程确实如此,...A=B=5;C=A+B;A=B=5;C=A+B;I=I=5;
2赞 supercat 9/24/2013
...编译器没有注意到两次写入都指向同一位置(如果一个或两个左值涉及指针,这可能很难确定),生成的代码可能会死锁。我不认为任何现实世界的实现将这种锁定作为其正常行为的一部分,但根据标准,这是允许的,如果硬件可以廉价地实现这种行为,它可能会很有用。在当今的硬件上,这种行为作为默认值实现的成本太高,但这并不意味着它总是如此。
1赞 dhein 9/24/2013
@supercat但是,仅 c99 的序列点访问规则是否足以将其声明为未定义的行为?因此,硬件在技术上可以实现什么并不重要?
76赞 badp 5/24/2010 #3

只需编译和反汇编你的代码行,如果你非常倾向于知道它到底是怎么回事,你就会得到你所得到的。

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

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

评论

0赞 bad_keypoints 9/24/2012
如何获取机器码?我使用Dev C++,并且在编译器设置中使用了“代码生成”选项,但没有额外的文件输出或任何控制台输出
5赞 badp 9/25/2012
@ronnieaka 和 → ,或任何 Windows 等效项:)gcc evil.c -c -o evil.bingdb evil.bindisassemble evil
29赞 Shafik Yaghmour 7/1/2014
这个答案并没有真正解决 的问题。Why are these constructs undefined behavior?
12赞 Kat 7/28/2015
顺便说一句,编译到汇编(使用 )会更容易,这就是这里所需要的。组装然后拆卸它只是一种迂回的方式。gcc -S evil.c
61赞 Steve Summit 2/17/2016
郑重声明,如果出于某种原因,你想知道一个给定的结构是做什么的——特别是如果有人怀疑它可能是未定义的行为——那么“用你的编译器试试看看”的古老建议可能是相当危险的。在这种情况下,您最多只能了解它在此版本的编译器下的作用。你不会学到太多关于它保证做什么的知识。通常,“只需使用编译器进行尝试”会导致只能与编译器一起使用的不可移植程序。
23赞 supercat 12/6/2012 #4

虽然任何编译器和处理器都不太可能真正这样做,但在 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++iipq(*p)(*q)ipq

64赞 Shafik Yaghmour 8/16/2013 #5

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

因此,在草案 c99 标准3 节中,继续讨论未指定的行为强调我的):6.5

运算符和操作数的分组由语法指示。74) 除非另有规定 后来(对于函数调用 ()、&&、||、?: 和逗号运算符),子表达式的计算顺序和副作用发生的顺序都未指定。

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

i = i++ + ++i;

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

我们在这里也有未定义的行为,因为程序在序列点之间多次修改变量(、等)。来自草案标准第2段(强调我的):iu6.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

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

并指出:

可能的未定义行为包括完全忽略结果不可预测的情况,到在翻译或程序执行过程中以环境特征的记录方式(有或没有发出诊断消息)的行为,再到终止翻译或执行(发出诊断消息)。

15赞 Nikhil Vidhani 9/11/2014 #6

C 标准规定,一个变量最多只能在两个序列点之间分配一次。例如,分号就是一个序列点。
所以每句话的形式:

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

等等违反了该规则。该标准还指出,行为是未定义的,而不是未指定的。一些编译器确实会检测到这些并产生一些结果,但这不是符合标准的。

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

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

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

评论

0赞 underscore_d 7/20/2016
当然,它不适用于一个表达式中的不同变量。如果是这样的话,那将是一个彻底的设计失败!在第二个示例中,您所需要的只是在语句结束和下一个语句开始之间递增,这是有保证的,正是因为序列点的概念是这一切的中心。
11赞 TomOnTime 4/8/2015 #7

在 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++ik[]++ik[]

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

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

50赞 Steve Summit 6/18/2015 #8

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

在我的书中,第一个被问到的片段显然是疯狂的。没有人会在真正的程序中编写它,它的作用并不明显,没有可以想象的算法可以有人试图编码,从而导致这种特定的人为操作序列。由于你和我都不清楚它应该做什么,所以在我的书中,如果编译器也不能弄清楚它应该做什么,那也没关系。i = i++ + ++i

第二个片段 ,更容易理解。看起来有人试图递增,并将结果分配回 。但是在 C 语言中有几种方法可以做到这一点。取 的值、加 1 并将结果赋回 的最基本方法在几乎任何编程语言中都是一样的:i = i++iiii

i = i + 1

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

i++

这也意味着,“取 的值,加 1,然后将结果赋回 ”。因此,如果我们构建两者的大杂烩,通过编写ii

i = i++

我们真正想说的是“取 的值,加上 1,将结果赋给 ,然后将结果赋给 ”。我们很困惑,所以如果编译器也感到困惑,也不会太困扰我。iii

实际上,这些疯狂的表达方式唯一一次被写出来的时候,是人们将它们用作应该如何工作的人为例子。当然,了解其工作原理也很重要。但一个实用的使用 规则是,“如果表达式 using 的含义不明显,就不要写它。++++++++

我们曾经在 comp.lang.c 上花费无数时间讨论这样的表达式以及为什么它们未定义。我的两个较长的答案,试图真正解释为什么,都存档在网络上:

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

评论

1赞 supercat 7/1/2015
关于未定义行为的一个相当令人讨厌的问题是,虽然它曾经在 99.9% 的编译器上是安全的,但这意味着情况不再如此。超现代的C语言需要编写类似后一种公式的东西(尽管没有标准的方式来表示代码不在乎里面有什么),以达到编译器用来提供前者的效率水平(该子句是必要的,以便让编译器优化一些较新的编译器所需的)。*p=(*q)++;if (p!=q) *p=(*q)++; else *p= __ARBITRARY_VALUE;*pelseif
0赞 Steve Summit 9/24/2019
@supercat我现在相信,任何足够“聪明”的编译器来执行这种优化,也必须足够聪明,可以窥视语句,这样程序员就可以在相关行之前加上一个简单的 .(当然,采取这种做法还需要重写,以免在非调试版本中直接删除断言,而是将它们转换为编译器本身可以看到的东西,然后不发出代码。assertassert(p != q)<assert.h>__builtin_assert_disabled()
0赞 RobertS supports Monica Cellio 7/5/2020
我们真正想说的是“将 1 加到 I,然后将结果赋回 I,然后将结果赋回 I”。---我认为有一个“并将结果分配回 i”太多了。
2赞 Steve Summit 7/6/2020
@RobertSsupportsMonicaCellio 诚然,它的写作方式有点令人困惑。将其读作“将 1 添加到从中获取的值,将结果赋值回 ,并将结果赋值回 ”。iii
1赞 Steve Summit 4/2/2023
@chux 是的,但你知道 C,并且你对自动增量运算符的正确定义感到满意。对这些运营商感到困惑的人不是!特别是,我相信如果你想象一些初学者的误解,他们就会更容易理解,并且基本上都是 .尽管如此,我已经软化了你评论的句子。++ii++i + 1
99赞 haccks 6/27/2015 #9

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

排序:(5.1.2.3)

给定任意两个计算值 和 ,如果排序在 之前,则 的执行应先于 的执行。ABABAB

未排序:

如果 之前或之后没有排序,则 和 是未排序的。ABAB

评估可以是以下两种情况之一:

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

序列点:

表达式的计算和 之间存在序列点,意味着与每个值计算和副作用相关联的 值计算和副作用都先于与 关联的每个值计算副作用进行排序。ABAB

现在来谈谈这个问题,对于像这样的表达方式

int i = 1;
i = i++;

标准说:

6.5 表达式:

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

因此,上面的表达式调用 UB,因为同一对象上的两个副作用相对于彼此是未排序的。这意味着没有排序通过赋值到的副作用是在副作用之前还是之后完成的。
根据赋值是在增量之前还是之后发生,将产生不同的结果,这就是未定义行为的情况之一。
ii++

让我们将赋值左边的 be 和赋值的右边(在表达式中)重命名为 be ,则表达式类似于iili++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。12++

32赞 P.P 12/31/2015 #10

通常,这个问题是与代码相关的问题的重复项,例如

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

这也具有未指定的行为,因为 和 的计算顺序是未指定的。但这是完全合法和有效的声明。此语句中没有未定义的行为。因为修改 ( 和 ) 是对不同的对象进行的。++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++);++ii6i++i6ji7

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

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

不会有问题。但它调用了未定义的行为,因为这里的逗号是一个分隔符


对于那些不熟悉未定义行为的人来说,阅读《每个 C 程序员都应该了解的未定义行为》将受益匪浅,以了解 C 语言中未定义行为的概念和许多其他变体。

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

评论

0赞 kavadias 10/18/2018
这个序列似乎给出了稳定的行为(gcc v7.3.0 中的从右到左的参数评估;结果“a=110 b=40 c=60”)。是因为赋值被视为“完整语句”,从而引入了一个序列点吗?这难道不应该导致从左到右的论点/陈述评估吗?或者,它只是未定义行为的表现?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));
1赞 P.P 10/18/2018
@kavadias 出于上述相同原因,该 printf 语句涉及未定义的行为。你分别在第三和第四个论点中写作,并在第二个论点中阅读。但是这些表达式(第 2、3 和第 4 个参数)之间没有序列。gcc/clang 有一个选项可以帮助找到这些。bc-Wsequence-point
18赞 Antti Haapala -- Слава Україні 3/26/2017 #11

虽然表达式的语法是合法的,但这些构造的行为是不确定的,因为 C 标准中的 shall 没有被遵守。C99 6.5p2a = a++a++ + a++

  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);逻辑 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中同一段的措辞是:

  1. 如果标量对象上的副作用相对于同一标量对象上的不同副作用或使用同一标量对象的值进行值计算是未排序的,则行为未定义。如果表达式的子表达式有多个允许的排序,则如果在任何排序中发生这种未排序的副作用,则行为是未定义的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,结果值就变成了表达式的值——也就是说,这只是一种人为的写作方式,又是一种“聪明”的写作方式iij = (i += 2)

i += 2;
j = i;

但是,in 函数参数列表不是逗号运算符,并且不同参数的计算之间没有序列点;相反,他们的评价彼此之间是没有顺序的;所以函数调用,

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

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

2赞 Mohamed El-Nakeep 6/11/2017 #12

原因是程序正在运行未定义的行为。问题在于计算顺序,因为根据 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 文档在优先级和评估顺序中所述:

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

评论

1赞 Antti Haapala -- Слава Україні 10/21/2017
我已经编辑了问题以在函数参数的评估中添加 UB,因为这个问题经常被用作重复问题。(最后一个例子)
1赞 Antti Haapala -- Слава Україні 10/21/2017
现在的问题是关于 c,而不是 C++
1赞 Steve Summit 1/23/2021
但是,如果您坚持使用一个编译器,您会发现该行为是持久的。嗯,不,不一定。例如,如果更改优化标志,编译器可能很容易最终发出代码,从而使未定义的行为行为不同。此外,如果您对附近的代码进行了看似无关的更改。
6赞 alinsoar 10/13/2017 #13

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 和 herehere

评论

0赞 haccks 11/24/2017
这个答案是如何为现有答案添加新的?此外,对的解释与这个答案非常相似。i=i++
0赞 alinsoar 11/24/2017
@haccks我没有阅读其他答案。我想用我自己的语言解释我从ISO 9899官方网站的上述文件中学到了什么 open-std.org/jtc1/sc22/wg14/www/docs/n1188.pdf
0赞 Soup Endless 3/1/2021
@haccks这个答案是可以的,除了它是你答案的副本之外,但我想问的是,所有其他答案在这里做什么,为什么他们有这么多的代表,而错过了主要问题点,那就是在示例中解释 UB 的细节。
1赞 P.P 3/17/2021
@SoupEndless 有很多答案,因为这是许多相似(但不是直接)重复项的规范问题。没有为同一问题的次要变体创建不同规范帖子的开销,其他人通常会在很久以后(通常是几年后!)发布答案,以使问题成为 dup 锤子的理想候选者。这就是这里发生的事情。重复同样的答案是没有意义的(尤其是几年后,它已经得到了答案!因此,后者的回答者并没有真正“错过重点”。这就是 SO 的工作原理。
14赞 Steve Summit 8/16/2018 #14

你的问题可能不是,“为什么这些结构在 C 中是未定义的行为?你的问题可能是,“为什么这个代码(使用)没有给我预期的价值?”,有人把你的问题标记为重复,然后把你送到这里。++

这个答案试图回答这个问题:为什么你的代码没有给你预期的答案,你如何学会识别(和避免)那些不能按预期工作的表达式。

我假设您现在已经听说了 C 和运算符的基本定义,以及前缀形式与后缀形式有何不同。但是这些运算符很难考虑,所以为了确保你理解,也许你写了一个小小的测试程序,涉及类似的东西++--++xx++

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 中没有规则说函数的参数按从左到右、从右到左或其他顺序计算。因此,我们不能说编译器是先做,然后是,然后是,还是先做,或者其他顺序。但顺序显然很重要,因为根据编译器使用的顺序,我们显然会得到一系列不同的数字。printfxx++++xx++xx++x++++xx

这种疯狂的表情呢?

x = x++ + ++x;

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

你可能会认为优先级、关联性或从左到右的评估会告诉你事情发生的顺序,但事实并非如此。你可能不相信我,但请相信我的话,我会再说一遍:优先级和关联性并不能决定 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

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


还有一件事。您可能想知道如何“修复”我通过介绍的开头给出的未定义表达式。

在 的情况下,这很简单——只需将其写成三个单独的调用:printf("%d %d %d\n", x, ++x, x++);printf

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

现在行为已经完全定义好了,你会得到合理的结果。

另一方面,在 的情况下,没有办法修复它。没有办法编写它来保证它的行为符合你的期望——但这没关系,因为你无论如何都不会像在真正的程序中那样编写表达式。x = x++ + ++xx = x++ + ++x

0赞 Persixty 7/16/2023 #15

理解这一点的关键是表达式的值是,它的效果是将 1 加到 (即将值存储在变量中),但这并不意味着在确定值时会发生存储。i++iii+1i

在像这样的表达式中,加法的左侧值是“i+1”,右侧的值是“i+1”。i++ + ++ii

但是,当任何一方的效果发生时,它是不确定的,因此整个表达式的值()是不确定的。i++ + ++i

对于编译者来说,注意到净效应是增加 2 并计算(等于和稍后存储在 中的内容)是完全合理的(并且可能是有效的),或者不这样做。ii+i+1i+2i

你不应该做的是尝试弄清楚你的编译器做了什么,然后发挥它的作用。

对编译器优化设置的更改,显然(对您来说!)对周围代码或编译器的新版本的不相关更改都可能改变行为。

你让自己对在看似不变的代码中突然出现的最耗时的错误类型之一敞开心扉。

编写您需要的代码(例如),并意识到所有现代商业编译器(当优化开启时)都会将其转换为适用于您平台的最有效代码。2*i+1; i+=2;

我什至建议永远不要在任何其他表达中使用,而不是独立使用,然后只是因为它易于阅读。不要以为它在某种程度上比所有现代商业编译器都会为两者发出相同的代码更有效率。他们不是傻瓜。++i=i+1