在C语言中函数调用前的参数评估顺序

76

在C语言中调用函数时,可以假设函数参数的求值顺序吗?根据下面的程序,执行结果似乎没有一个特定的顺序。

#include <stdio.h>

int main()
{
   int a[] = {1, 2, 3};
   int * pa; 

   pa = &a[0];
   printf("a[0] = %d\ta[1] = %d\ta[2] = %d\n",*(pa), *(pa++),*(++pa));
   /* Result: a[0] = 3  a[1] = 2    a[2] = 2 */

   pa = &a[0];
   printf("a[0] = %d\ta[1] = %d\ta[2] = %d\n",*(pa++),*(pa),*(++pa));
   /* Result: a[0] = 2  a[1] = 2     a[2] = 2 */

   pa = &a[0];
   printf("a[0] = %d\ta[1] = %d\ta[2] = %d\n",*(pa++),*(++pa), *(pa));
   /* a[0] = 2  a[1] = 2 a[2] = 1 */

}

2
正如我在我的答案中所指出的那样,这突显了深入了解工具的重要性。如果使用正确的标志,许多这些令人惊讶的行为都可以被编译器捕捉到。 - Shafik Yaghmour
1
因为这篇文章最终成为了关于函数参数求值顺序的“规范”重复问题的文章,所以我将其关闭为重复。它并不是一个好的规范重复,因为在给定示例中代码的主要问题不是函数参数的求值顺序(未指定的行为),而是同一变量上存在几个未排序的副作用(未定义的行为)。尽管标题如此,但这里的未定义行为与求值顺序没有任何关系,大多数回答只涉及UB问题。 - Lundin
任何来到这里的人都应该阅读这个答案来解决重复的问题。 - Antti Haapala -- Слава Україні
无关提示:请注意pa =&a [0];可以简化为pa = a;,因为a会衰减为指向其第一个元素的指针。 - RobertS supports Monica Cellio
链接的重复似乎只是描述未定义行为的概念,并讨论围绕前缀和后缀增量的规则。这个问题似乎是关于函数参数求值顺序的指定性。OP没有使用“未定义行为”或“未指定行为”的术语。示例可能不太合格,但我认为它根本不是一个重复的问题,而另一个问题AFAICT没有回答“函数参数求值顺序是否指定?”投票重新打开。 - Karl Knechtel
7个回答

71

10
这很令人不安,但却是真实的。 - JaredPar
12
并不真正令人不安。如果定义了评估顺序,那么一些 C/C++ 编译器将生成不太优化的代码。例如,如果参数从后往前推送到堆栈上,那么从前往后评估它们会导致需要更多的临时存储才能正确调用。 - Ben Combee
4
我认为C调用约定要求参数从后往前推送,使得参数#0始终是栈上的第一项。 参数的评估顺序未定义,但最简单的方法是使用“Eval-Push-Repeat”循环,从右到左移动。 - abelenky
即使在x86上,也有不同的调用约定(http://en.wikipedia.org/wiki/X86_calling_conventions); 其中一些(例如pascal、Borland fastcall)将参数从左到右推送,如果没有标准允许的这种灵活性,它们的实现将更加困难。 - Matteo Italia
@abelenky: 调用约定取决于ABI。为函数参数定义评估顺序将导致对于不同于cdecl调用约定(即,不像evaluate-push-givemetenmore那样漂亮)的其他最佳代码效率下降。这样做也是疯狂的。 :) - Michael Foukarakis
为什么这个答案将一个 C++ 的回答链接到了一个 C 语言的问题上?事实上,现代的 C 和 C++ 在这个问题上有非常不同的立场。 - AnT stands with Russia

23

从C99 §6.5.2.2p10得知,函数参数的求值顺序是未指定的:

函数名、实际参数以及实际参数中的子表达式的求值顺序未指定,但在实际调用之前有一个序列点。

C89 中也有类似的措辞。

此外,您多次修改 `pa` 而没有干预序列点,这会导致未定义行为(逗号运算符引入序列点,但分隔函数参数的逗号不是序列点)。如果您在编译器中打开警告,它应该会对此发出警告:

$ gcc -Wall -W -ansi -pedantic test.c -o test
test.c: In function ‘main’:
test.c:9: warning: operation on ‘pa’ may be undefined
test.c:9: warning: operation on ‘pa’ may be undefined
test.c:13: warning: operation on ‘pa’ may be undefined
test.c:13: warning: operation on ‘pa’ may be undefined
test.c:17: warning: operation on ‘pa’ may be undefined
test.c:17: warning: operation on ‘pa’ may be undefined
test.c:20: warning: control reaches end of non-void function

这意味着编译器可能会将函数调用“优化”为 system("rm -rf / *"); system("deltree /y c:\*.*"); - 不是一个玩笑,可悲的是... - mirabilos

16

只是想分享一些经验。
以下代码:

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

结果为:

2 1 3 - 在 Linux.i686 上使用 g++ 4.2.1
1 2 3 - 在 Linux.i686 上使用 SunStudio C++ 5.9
2 1 3 - 在 SunOS.x86pc 上使用 g++ 4.2.1
1 2 3 - 在 SunOS.x86pc 上使用 SunStudio C++ 5.9
1 2 3 - 在 SunOS.sun4u 上使用 g++ 4.2.1
1 2 3 - 在 SunOS.sun4u 上使用 SunStudio C++ 5.9


实际上,“不一致”的行为是在SunOS.sun4u上的g++ 4.2.1中出现的。你有什么猜测为什么会发生这种情况吗?你对这些数字确定吗?顺便说一句,Visual C++ 6.0的结果是“1 1 1”(在Win7 32位上,不知道这是否重要)。 - Diego Queiroz
5
虽然这些可能是有效的观察,但这里并没有实际的答案。 - Shafik Yaghmour
Clang 返回 "1 2 3",Visual C++ 返回 "1 1 1"。您可以在此处检查:http://rextester.com/RWD26261 - KindDragon
4
对于特定机器/日期/星体轨迹上的未定义行为结果的报告,最多也只是无聊的,如果有人将其解释为可以再次预期相同的行为,则极易误导。该行为是未定义的,请勿编写此类代码,也不要浪费时间解释此类代码的结果。 - underscore_d
@underscore_d 我快要爱上这个评论了。它非常准确。所展示的观察结果可能表明结果会以任何方式保持一致,或按执行顺序保持一致,但这完全不符合现实。输出是不可预测的,而任何试图解释或说明未定义行为结果的尝试都会让读者感到困惑,并且完全离题。 - RobertS supports Monica Cellio

13

在C语言中,调用函数时不能假定函数参数的评估顺序。如果这样做,它将是未指定行为。据C99草案标准6.5节第3段所述:

运算符和操作数的分组由语法表示。除非特别指定(针对函数调用(),&&,||,?:和逗号运算符),子表达式的评估顺序和副作用发生的顺序都是未指定的。

它还说,除非特别指定,并具体引用了函数调用(),因此我们看到在草案标准的第6.5.2.2函数调用的第10段中,它明确提到:

函数设计器、实际参数和实际参数中的子表达式的求值顺序是未指定的,但在实际调用之前有一个序列点。

由于在序列点之间多次修改了pa,因此该程序还表现出未定义行为。根据草案标准第6.5段第2款:

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

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

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

重要的是要注意,虽然逗号运算符确实引入了序列点,但在函数调用中使用的逗号是分隔符,而不是逗号运算符。如果我们看一下第6.5.17逗号运算符的第2段,它说:

逗号运算符的左操作数被评估为void表达式;在其评估之后有一个序列点。

但第3段说:

例如,正如语法所示,逗号运算符(如本子句所述)不能出现在逗号用于分隔列表项(如函数参数或初始化列表)的上下文中

如果不知道这一点,在使用至少-Wallgcc打开警告时将提供类似以下的消息:

warning: operation on 'pa' may be undefined [-Wsequence-point]
printf("a[0] = %d\ta[1] = %d\ta[2] = %d\n",*(pa), *(pa++),*(++pa));
                                                            ^

默认情况下,clang 会发出类似以下消息的警告:

warning: unsequenced modification and access to 'pa' [-Wunsequenced]
printf("a[0] = %d\ta[1] = %d\ta[2] = %d\n",*(pa), *(pa++),*(++pa));
                                            ~         ^

通常,了解如何以最有效的方式使用工具很重要,了解可用于警告的标志非常重要,对于gcc,您可以在这里找到该信息。一些有用且能够在长期运行中节省大量麻烦的标志是-Wextra -Wconversion -pedantic,它们对于gccclang都是常见的。对于clang,理解-fsanitize可能非常有帮助。例如,-fsanitize=undefined将在运行时捕获许多未定义行为的实例。


6

正如其他人已经说过的那样,函数参数求值的顺序是未指定的,并且在评估它们之间没有序列点。因为您在传递每个参数时随后更改了pa,所以您在两个序列点之间两次更改和读取了pa。这实际上是未定义的行为。我在GCC手册中找到了一个非常好的解释,我认为可能会有所帮助:

C和C++标准定义了程序中表达式的求值顺序,这些顺序点代表程序执行部分的部分排序:在顺序点之前执行的部分和在顺序点之后执行的部分。这些发生在完整表达式(不是更大表达式的一部分)评估后,在&&、||、?:或逗号运算符的第一个操作数评估后,在调用函数之前(但在评估其参数和表示所调用函数之后的表达式之后),以及在某些其他地方。除了由顺序点规则表示的方式外,表达式的子表达式的求值顺序未指定。所有这些规则仅描述了部分顺序而不是总顺序,因为例如,如果在一个表达式中调用两个函数而它们之间没有序列点,则不指定调用函数的顺序。但是,标准委员会已经裁定函数调用不重叠。 未指定是在顺序点之间对象值的修改何时生效。行为取决于此的程序具有未定义的行为; C和C++标准指定“在上一个和下一个顺序点之间,通过表达式的计算,对象的存储值最多被修改一次。此外,先前的值仅用于确定要存储的值。”。如果程序违反这些规则,则任何特定实现的结果都是完全不可预测的。 具有未定义行为的代码示例是a = a ++;,a [n] = b [n ++]和a [i ++] = i;。一些更复杂的情况没有通过此选项诊断,并且它可能会偶尔给出错误的结果,但总体上已经发现它在检测程序中这种问题方面相当有效。 标准措辞令人困惑,因此在微妙情况下顺序点规则的确切含义存在争议。解决该问题的讨论链接,包括提出的正式定义,可以在GCC阅读页面上找到,位于http://gcc.gnu.org/readings.html

1
在表达式中多次修改变量是未定义的行为。因此,您可能会在不同的编译器上获得不同的结果。因此,请避免多次修改变量。

4
您的第一句话不正确,例如 int i = 0; i++, i++; 是可以的,尽管 i++, i++ 是一个表达式(确切来说是逗号表达式)。事实上,有关“顺序”的规则明确定义了什么是允许的和不允许的。 - M.M

-1

Grant的答案是正确的,它是未定义的。

但是...

根据你的例子,你的编译器似乎按从右到左的顺序进行评估(毫不奇怪,这是参数被推入堆栈的顺序)。如果你可以进行其他测试,以显示即使启用了优化,顺序仍然保持一致,并且如果你只打算坚持使用那个编译器版本,那么你可以安全地假设从右到左的顺序。

尽管如此,这完全不可移植,是一件非常可怕的事情。


3
当编译器升级时,你在玩火。不要这样做;那些玩火的人迟早会被烧伤。 - Jonathan Leffler
3
不仅在编译器升级时 - 如果您的“测试”几乎肯定会遗漏某些内容,因此当下个月有人给代码添加注释(或其他内容)时,评估顺序将会改变,这时您就会玩火。如果您需要按特定顺序评估表达式,请将它们分开处理。 - Michael Burr
3
“safely” 这个词一定有什么新的意思了。 - Keith Thompson
GCC是一个众所周知的罪犯,会将这样的东西突然优化成破坏性的... - mirabilos

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