ChatGPT解决这个技术问题 Extra ChatGPT

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

#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?
}
@Jarett,不,只需要一些指向“序列点”的指针。在工作时,我发现了一段 i = i++ 的代码,我认为“这不是在修改 i 的值”。我测试了,我想知道为什么。因为,我已经删除了这个声明并用 i++ 替换它;
我认为有趣的是,每个人总是假设提出这样的问题是因为提问者想要使用有问题的构造。我的第一个假设是 PiX 知道这些是不好的,但是很好奇为什么他们在他/她使用的 whataver 编译器上的行为方式......是的,unWind 所说的......它是未定义的,它可以做任何事情。 ..包括JCF(跳跃和着火)
我很好奇:为什么编译器似乎不对诸如“u = u++ + ++u;”之类的结构发出警告如果结果未定义?
无论括号如何,(i++) 的计算结果仍为 1
无论 i = (i++); 打算做什么,肯定有一种更清晰的方式来编写它。即使定义明确也是如此。即使在定义 i = (i++); 行为的 Java 中,它仍然是糟糕的代码。只需写 i++;

D
DaveRandom

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

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

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

你最有趣的例子,那个

u = (u++);

是未定义行为的教科书示例(参见 Wikipedia 在 sequence points 上的条目)。


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

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

排序:(5.1.2.3)

给定任意两个评估 A 和 B,如果 A 在 B 之前排序,则 A 的执行应先于 B 的执行。

未排序:

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

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

值计算,计算出表达式的结果;和

副作用,即对象的修改。

序列点:

在表达式 A 和 B 的求值之间存在一个序列点意味着与 A 相关的每个值计算和副作用都在与 B 相关的每个值计算和副作用之前排序。

现在来到这个问题,对于像这样的表达

int i = 1;
i = i++;

标准说:

6.5 表达式:

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

因此,上面的表达式调用了 UB,因为对同一对象 i 的两个副作用相对于彼此是无序的。这意味着分配给 i 的副作用将在 ++ 的副作用之前还是之后完成是没有顺序的。
根据分配是在增量之前还是之后发生,将产生不同的结果和这就是未定义行为的情况之一。

让赋值左侧的 i 重命名为 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.  

An important point 关于 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。


C
Christoph

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

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

和 6.5.16 赋值运算符,§4:

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


以上是否暗示 'i=i=5;" 将是未定义的行为?
@supercat 据我所知 i=i=5 也是未定义的行为
@Zaibis:我喜欢在大多数地方使用的基本原理规则适用于理论上多处理器平台可以将 A=B=5; 之类的东西实现为“写锁 A;写锁 B;将 5 存储到 A;将 5 存储到 B ; Unlock B; Unock A;”,以及类似 C=A+B; 的语句为“Read-lock A; Read-lock B; Compute A+B; Unlock A and B; Write-lock C; Store result; Unlock C;” .这将确保如果一个线程执行 A=B=5; 而另一个线程执行 C=A+B;,则后一个线程要么将两个写入都视为已发生,要么均不发生。潜在的有用保证。但是,如果一个线程执行 I=I=5;,...
...并且编译器没有注意到两次写入都指向同一个位置(如果一个或两个左值都涉及指针,这可能很难确定),生成的代码可能会死锁。我不认为任何现实世界的实现都将这种锁定作为其正常行为的一部分,但在标准下这是允许的,如果硬件可以廉价地实现这种行为,它可能会很有用。在今天的硬件上,这种行为作为默认实现过于昂贵,但这并不意味着它总是如此。
@supercat 但仅 c99 的序列点访问规则不足以将其声明为未定义的行为吗?因此,硬件可以在技术上实现什么并不重要?
b
badp

只需编译和反汇编您的代码行,如果您如此倾向于知道它是如何准确地得到您所得到的。

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

$ 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++,我在编译器设置中使用了“代码生成”选项,但没有额外的文件输出或任何控制台输出
@ronnieaka gcc evil.c -c -o evil.bingdb evil.bindisassemble evil,或任何 Windows 等价物:)
这个答案并没有真正解决 Why are these constructs undefined behavior? 的问题。
顺便说一句,编译成程序集(使用 gcc -S evil.c)会更容易,这就是这里所需要的。组装然后拆卸它只是一种迂回的方式。
作为记录,如果您出于某种原因想知道给定构造的作用——特别是如果怀疑它可能是未定义的行为——“只需用你的编译器试试看”的古老建议是可能相当危险。今天,在这种情况下,您最多将了解它在此版本的编译器下的作用。如果有任何关于它保证做什么的事情,你不会学到太多。一般来说,“只用你的编译器试试”会导致只能用你的编译器工作的不可移植的程序。
C
Community

该行为无法真正解释,因为它同时调用 unspecified behaviorundefined behavior,因此我们无法对此代码做出任何一般性预测,尽管如果您阅读 Olve Maudal 的 作品,例如 {3 } 和 Unspecified and Undefined 有时您可以使用特定的编译器和环境在非常特定的情况下做出很好的猜测,但请不要在生产附近的任何地方这样做。

所以继续未指定的行为,在draft c99 standard部分6.53 段中说(强调我的):

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

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

i = i++ + ++i;

我们不知道是先评估 i++ 还是 ++i。这主要是给编译器better options for optimization

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

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

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

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

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

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

i = (i++);
^    ^

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

u = (u++);
^    ^

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

未指定的行为3.4.4 部分的 draft c99 standard 中定义为:

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

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

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

并指出:

可能的未定义行为范围从完全忽略具有不可预测结果的情况,到在翻译或程序执行期间以环境特征的记录方式表现(有或没有发出诊断消息),到终止翻译或执行(有发出的诊断消息)。


S
Steve Summit

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

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

为什么标准没有定义这些做什么?

运算符优先级不决定评估顺序吗?

另请参阅 question 3.8C FAQ listsection 3 中的其余问题。


关于未定义行为的一个相当讨厌的问题是,虽然在 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() 的东西,编译器可以看到,然后不为其发出代码。 )
我们真正要说的是“将 1 加到 i,并将结果分配回 i,并将结果分配回 i”。 ---我认为有一个“并将结果分配回i”太多了。
@RobertSsupportsMonicaCellio 诚然,它的编写方式有点令人困惑。将其读作“对从 i 获取的值加 1,将结果分配回 i,并将结果分配回 i”。
“当然,C 有一个方便的快捷方式:i++” 哈哈,不错。或者最坏的情况,i += 1。
F
Felipe Augusto

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

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

或者

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

或类似的变体。

虽然这也是如前所述的 undefined behaviour,但在与以下语句进行比较时,涉及 printf() 时存在细微差别:

x = i++ + i++;

在以下声明中:

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

printf() 中的参数 order of evaluationunspecified。这意味着,表达式 i++++i 可以按任何顺序计算。 C11 standard 对此有一些相关描述:

附件 J,未指明的行为

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

3.4.4、未指明的行为

使用未指定的值,或本国际标准提供两种或多种可能性的其他行为,并且在任何情况下都没有强加进一步的要求。示例未指定行为的一个示例是计算函数参数的顺序。

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

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

这也有未指定的行为,因为 ++xy++ 的评估顺序未指定。但这是完全合法有效的声明。此语句中有 no 未定义的行为。因为修改(++xy++)是针对 distinct 对象进行的。

什么使以下陈述

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

因为 未定义的行为 是这两个表达式修改了 相同 对象 i 而没有干预 sequence point

另一个细节是 printf() 调用中涉及的 逗号分隔符,而不是 comma operator

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

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++); 中,++ii 增加到 6 并且 i++ 产生分配给 ji (6) 的旧值。然后 i 由于后增量变为 7

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

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

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

对于那些不熟悉未定义行为的人来说,阅读 What Every C Programmer Should Know About Undefined Behavior 以了解 C 中未定义行为的概念和许多其他变体会受益匪浅。

这篇文章:Undefined, unspecified and implementation-defined behavior 也是相关的。


这个序列 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 该 printf 语句涉及未定义的行为,原因与上述相同。您在 3rd & 中写 bc分别是第四个论点和第二个论点的阅读。但是这些表达式(第 2、第 3 和第 4 个参数)之间没有顺序。 gcc/clang 有一个选项 -Wsequence-point 也可以帮助找到这些。
s
supercat

尽管任何编译器和处理器实际上都不太可能这样做,但根据 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 的情况下检测到问题,但是如果例程接受对两个指针 pq 的引用,并使用 (*p)和上述表达式中的 (*q)(而不是两次使用 i)编译器不需要识别或避免如果同时为 pq 传递相同对象的地址会发生的死锁。


A
Antti Haapala -- Слава Україні

虽然 a = a++a++ + a++ 等表达式的语法是合法的,但这些构造的行为未定义,因为 不遵守 C 标准中的 shallC99 6.5p2

在前一个和下一个序列点之间,对象的存储值最多只能通过表达式的评估修改一次。 [72] 此外,应仅读取先验值以确定要存储的值 [73]

footnote 73 进一步阐明

本段呈现未定义的语句表达式,例如 i = ++i + 1; a[i++] = i;同时允许 i = i + 1; a[i] = i;

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

以下是 5.1.2.3 中描述的顺序点: 在函数调用和实际调用中函数指示符和实际参数的评估之间。 (6.5.2.2)。在以下运算符的第一个和第二个操作数的计算之间:逻辑与 && (6.5.13);逻辑或 || (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)。

同一个 paragraph in C11 的措辞是:

如果标量对象上的副作用相对于同一标量对象上的不同副作用或使用相同标量对象的值的值计算是未排序的,则行为未定义。如果一个表达式的子表达式有多个允许的排序,则如果在任何排序中出现这种未排序的副作用,则行为未定义。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

重要的部分是了解what a sequence point is -- and what is a sequence point and what isn't。例如 逗号运算符 是一个序列点,所以

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,在前一个和下一个序列点之间。


N
Nikhil Vidhani

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

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

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

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

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

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


当然,它不适用于一个表达式中的不同变量。如果这样做,那将是一个彻底的设计失败!在第二个示例中,您所需要的只是在语句结束和下一个开始之间递增,这是可以保证的,这正是因为序列点位于所有这一切的中心的概念。
C
Community

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

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


S
Steve Summit

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

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

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

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

问题是,在实际调用 printf 之前,编译器是先计算 x 的值,还是先计算 x++,还是先计算 ++x?但事实证明我们不知道。 C 中没有规定函数的参数从左到右、从右到左或以其他顺序计算。所以我们不能说编译器会先执行 x,然后是 ++x,然后是 x++,还是先执行 x++,然后执行 ++x,然后执行 x,或者其他顺序。但是顺序显然很重要,因为根据编译器使用的顺序,我们会清楚地打印出一系列不同的数字。

这个疯狂的表情是怎么回事?

x = x++ + ++x;

这个表达式的问题在于它包含三种不同的修改 x 值的尝试:(1) x++ 部分尝试获取 x 的值,加 1,将新值存储在 x ,并返回旧值; (2) ++x部分尝试取x的值,加1,将新值存入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 的示例,在表达式中

x = x++ + ++x;

有 3 次尝试修改 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++ + ++x 的情况下,无法修复它。没有办法编写它以保证其行为符合您的期望——但这没关系,因为无论如何您永远不会在实际程序中编写像 x = x++ + ++x 这样的表达式。


a
alinsoar

the ISO W14 site 的文档 n1188 中提供了关于这种计算中发生的情况的一个很好的解释。

我解释这些想法。

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

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

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

在我上面引用的论文中,解释说您可以将程序视为由小盒子组成,每个盒子包含两个连续序列点之间的指令。序列点在标准的附件 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 站点(也已发布 as a book)的条目,即 hereherehere


该答案如何为现有答案添加新内容? i=i++ 的解释也与 this answer 非常相似。
@hacks 我没有阅读其他答案。我想用我自己的语言解释我从 ISO 9899 官方网站上提到的文档中学到的知识open-std.org/jtc1/sc22/wg14/www/docs/n1188.pdf
@hacks这个答案是好的,除了它是你的答案的副本,但我会问,所有其他答案在这里做什么以及为什么他们有这么多代表却错过了问题的要点,这解释了细节UB 在示例中。
@SoupEndless 有很多答案,因为这是许多类似(但不是直接)重复项的规范问题。如果没有为同一问题的次要变体创建不同的规范帖子的开销,其他人通常会在很久以后(通常是几年后!)发布答案,以使问题成为 duphams 的理想候选者。这就是这里发生的事情。重复相同的答案是没有意义的(尤其是几年后,它已经被回答了!)。所以后面的回答者并没有真正“错过重点”。这就是 SO 的工作原理。
V
VLL

原因是程序正在运行未定义的行为。问题在于求值顺序,因为根据 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++: Operators

在 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 文档在 Precedence and Order of Evaluation 中所述:

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


我已经编辑了问题以在函数参数的评估中添加 UB,因为这个问题经常被用作该问题的副本。 (最后一个例子)
现在的问题是关于 c,而不是 C++
但是,如果您坚持使用一种编译器,您会发现行为持续存在。嗯,不,不一定。例如,如果您更改优化标志,编译器可能很容易最终发出使未定义行为表现不同的代码。此外,如果您对附近的代码进行看似无关的更改。