这些使用前置和后置递增的结构为什么会产生未定义行为?

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

16
@Jarett,不需要,我只是需要一些关于“序列点”的指导。在工作中,我发现了一段代码 i=i++,我认为“这并没有修改i的值”。我进行了测试,然后开始疑惑。自那以后,我删除了这个语句,并将其替换为 i++。 - PiX
231
我觉得有趣的是每个人都总是假定像这样的问题被问是因为提问者想要在所讨论的结构中使用它。我的第一个假设是,PiX 知道这些是不好的,但是他/她很好奇为什么在他/她使用的编译器上它们会以这种方式运作... 而且就像 unWind 说的那样... 这是未定义的,它可以做任何事情... 包括 JCF(跳转并着火)。 - Brian Postow
40
为什么编译器不会在类似“u = u++ + ++u;”这样的语句中发出警告,即使结果未定义? - Learn OpenGL ES
5
无论加不加括号,(i++) 的值仍为1。 - Drew McGowen
3
无论 i = (i++); 的目的是什么,肯定有更清晰的写法。即使它被明确定义了,这也是正确的。即使在 Java 中定义了 i = (i++); 的行为,这仍然是糟糕的代码。只需编写 i++; 即可。 - Keith Thompson
显示剩余13条评论
15个回答

14

你的问题可能不是,“为什么这些构造在 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. 如果有一个变量在两个或更多不同的地方被修改(赋值),你怎么知道哪个修改先发生?
  2. 如果有一个变量在一个地方被修改,并在另一个地方使用它的值,你怎么知道它使用旧值还是新值?

作为第1个例子,在以下表达式中:

x = x++ + ++x;

有三次尝试修改 x

作为第二个示例,在表达式中:

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这样的表达式。


11

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 的增量操作不能保证在其他计算之前全部完成。实际上,不同的编译器将会得到不同的结果。在您提供的示例中,前两个 ++i 执行了,然后读取了 k[] 的值,然后执行了最后一个 ++i,然后是k[]

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

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


6
在这种计算中发生的情况在文档n1188中提供了一个很好的解释,该文档来自ISO W14 site。 我会讲解这些想法。
适用于这种情况的标准ISO 9899的主要规则是6.5p2。

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

i=i++这样的表达式中的序列点位于i=之前和i++之后。
在我引用的论文中,可以将程序构建为包含介于两个连续序列点之间指令的小方框。序列点在标准的附录C中定义,在i=i++的情况下,有两个序列点限定一个完全表达式。 这样的表达式在语法上等同于Backus-Naur形式的语法(Annex A中提供了一个语法)中的expression-statement条目。
因此,盒子内指令的顺序没有明确的顺序。
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官方网站提到的文件中学到的内容 http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1188.pdf - alinsoar
@haccks 这个答案还不错,除了它是你的答案的副本之外。但我想问一下,其他所有答案在这里做什么,为什么它们有那么多声望,而且错过了问题的主要点,即用例中解释UB的细节。 - Soup Endless
1
@SoupEndless 这个问题是一些类似但不完全相同的重复问题的规范问题,所以有很多答案。为了避免为同一问题的微小变体创建不同的规范帖子,通常其他人会在很久之后(甚至几年之后!)发布答案,使问题成为重复问题的理想候选项。这就是这里发生的事情。重复相同的答案是毫无意义的(特别是在几年之后已经有答案的情况下)。因此,后来的回答者并没有真正“错过重点”。这就是 SO 的工作方式。 - P.P

2
原因是程序运行的未定义行为。问题在于评估顺序,因为根据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++中,运算符的优先级控制着按照哪个顺序计算各个运算符

在C++中,定义了等效代码的行为方式,GCC也能理解。
#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是如何工作的?它采用另一种方法,首先在第一遍评估所有的预增量表达式,然后在第二遍使用变量值进行操作,在第三遍将右手边的值分配给左手边,在最后一遍中,它将在一个遍历中评估所有的后增量表达式。
因此,在C++中,与Visual 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
我已经编辑了问题,添加了在评估函数参数时的未定义行为(UB),因为这个问题经常被用作该问题的重复。(最后一个示例) - Antti Haapala -- Слава Україні
1
现在问题是关于 [tag:c],而不是 C++。 - Antti Haapala -- Слава Україні
1
然而,如果您坚持使用一个编译器,您会发现其行为是一致的。但实际情况并非如此。例如,如果您更改了优化标志,编译器可能很容易地生成使未定义行为表现不同的代码。此外,如果您对相邻的代码进行了看似不相关的更改,也会产生影响。 - Steve Summit

0
理解这一点的关键是,表达式i++i,它的作用是将1加到i(即将i+1的值存储在变量i中),但这并不意味着当确定值时就会发生存储。
在像i++ + ++i这样的表达式中,加法左侧的值是i,右侧的值是i+1
但是,无论哪一边的效果何时发生都是未定义的,所以整个表达式的值(i++ + ++i)是未定义的。
合理地(可能也更高效地)注意到净效果是将i增加2,并计算(相当于i+i+1)后将i+2存储在i中。或者不存储。
你不应该试图弄清楚编译器的行为并按照它来操作。
编译器优化设置的更改,与周围代码明显(对你而言!)无关的更改,或者编译器的新版本都可能改变行为。
你会遇到一种最耗时的错误类型,即使代码看起来没有改变,它也会突然出现。
写出你需要的代码(例如2*i+1; i+=2;),并意识到所有现代商业编译器(在优化开启时)都会将其转换为最高效的平台代码。
我甚至建议除了独立使用以外,永远不要在其他表达式中使用++,而且仅仅因为它易于阅读。不要想象它比i=i+1更高效,因为所有现代商业编译器都会为两者生成相同的代码。它们不傻。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接