函数调用中参数的求值顺序是什么?

11
我正在研究C语言中的未定义行为,发现有一条声明称:

函数参数没有特定的计算顺序

但是标准调用约定(如"_cdecl"和"_stdcall")的定义指出(书中),参数从右到左进行计算。
现在我对这两个定义感到困惑,一个根据未定义行为的规定,另一个则符合调用约定的定义。请解释这两种情况的正当性。

2
看起来你正在UB攻读博士学位。 - haccks
阅读标准,抛开书本 :-) - Marichyasana
@Marichyasana:我没有看到任何迹象表明这本书是错误的。 - Keith Thompson
你的实现可以随意对于未定义行为(UB)做出任何保证,因为标准明确允许发生任何事情。目前尚未发现矛盾之处。但请务必十分确定书中是正确的,因为调用规约仅描述调用方和被调用方之间的接口,没有权利干预它们如何获得(或不获得)所需的结果。即使书中是正确的,我也强烈建议您避免使用任何此类保证,因为它们是不可移植的,并且在平台上可能并非所有编译器都遵循。 - Deduplicator
标准的美妙之处在于可供选择的标准如此之多。虽非原创,但很恰当。如果您想了解的是参考资料,那将会有所帮助。_cdecl_stdcall约定不是C标准的一部分,它们属于微软。这些约定可能定义了函数调用时参数在栈上放置的顺序/顺序(我认为它们也与哪个代码在函数调用后恢复堆栈指针有关),但即使如此也不必决定求值顺序。 - Jonathan Leffler
6个回答

15

正如Graznarak的答案所正确指出的,参数计算的顺序与参数传递的顺序是不同的。

ABI通常仅适用于参数传递的顺序,例如使用哪些寄存器和/或将参数值推送到堆栈的顺序。

C标准规定评估的顺序是未指定的。例如(请记住printf返回一个int结果):

some_func(printf("first\n"), printf("second\n"));

C标准规定这两个消息将以某种顺序打印(评估不交错),但明确指出不说选择哪个顺序。它甚至可以在一个调用和下一个调用之间变化,而不违反C标准。甚至可以先评估第一个参数,然后评估第二个参数,然后将第二个参数的结果压入堆栈,再将第一个参数的结果压入堆栈。

ABI可能会指定用于传递这两个参数的寄存器,或者在堆栈上推送值的确切位置,这完全符合C标准的要求。

但是,即使ABI实际上要求评估按指定顺序进行(例如,打印"second\n",然后是"first\n"将违反ABI),这仍与C标准一致。

C标准所说的是,C标准本身并没有定义评估的顺序。一些次要的标准仍然可以这样做。

顺便说一句,这本身并不涉及未定义行为。有些情况下,未指定的评估顺序可以导致未定义的行为,例如:

printf("%d %d\n", i++, i++); /* undefined behavior! */

但是你最后的例子为什么会出现未定义行为呢? - OldSchool
1
单个变量的多次赋值,在没有顺序点的情况下是未定义的。 - Graznarak
1
@Bayant_singh,这是UB,因为您不知道增量的顺序和存储增量值的时间。假设在指令之前i==0,所有结果(0,0),(0,1)和(1,0)都是可能的。 - CiaPan
1
@CiaPan:由于行为是未定义的,所有结果(2,3),(“foo”,“bar”)和“我很抱歉,戴夫,我恐怕不能这样做”也都有可能。 - Keith Thompson
3
就ISO C标准而言,这是完全错误的。标准甚至没有提到“stack”这个词,并且除了一些运算符(&&||?:)之外,也没有指定参数或操作数的评估顺序。即使对于使用堆栈的实现,参数的求值顺序与它们在内存中出现的顺序之间也没有必然关系。优化编译器可以并且确实会违反您的假设。建议阅读:comp.lang.c FAQ的第3节。 - Keith Thompson
显示剩余4条评论

7

_cdecl_stdcall 仅仅指定参数按照从右到左的顺序压入堆栈,并不意味着参数会按照这个顺序被求值。如果调用约定像_cdecl, _stdcallpascal改变了参数求值的顺序,那么你必须知道所调用函数的调用约定,才能理解自己的代码行为。如果这样,就会发生数据泄漏。在某些你没有编写的头文件里面,就会有一个神秘的密钥来理解你正在编写的一行代码;但是你有几十万行,每行的行为都不同吗?这将是疯狂的。

我觉得 C89 中许多未定义的行为都源于标准是在存在多个冲突实现之后编写的。他们可能更关心达成大多数实现者能够接受的基线,而不是定义所有行为。我想所有 C 中未定义的行为只是一群聪明而充满激情的人们达成不同意见的地方,但我不在场。

我现在很想分叉一个 C 编译器,并使其评估函数参数,就好像我正在运行广度优先遍历的二叉树一样。在未定义的行为上,您永远无法玩得太过瘾!


7
参数评估和参数传递是相关但不同的问题。
参数往往从左到右传递,通常一些参数会被传递到寄存器中,而不是在栈上传递。这是ABI和_cdecl、_stdcall所规定的。
将参数评估后放置到函数调用所需位置的顺序是未指定的。它可以从左到右,从右到左或其他顺序进行评估。这取决于编译器,并且甚至可能因优化级别而有所不同。

请详细解释 - OldSchool

2

请查看您提到的书籍,看看是否有任何关于“序列点”的参考资料,因为我认为这就是您想要了解的内容。

基本上,序列点是一个点,一旦到达该点,您可以确定所有前面的表达式都已完全评估,并且其副作用肯定不再存在。

例如,初始化器的末尾就是一个序列点。这意味着在:

bool foo = !(i++ > j);

你可以肯定的是,i 将会等于它初始值加一,并且 foo 已经被赋值为 true 或者 false。另一个例子:

int bar = i++ > j ? i : j;

是完全可预测的。它的读法如下:如果i当前值大于j,并在此比较之后将i加一(问号是一个序列点,因此在比较之后,i被递增),则将i(新值)分配给bar,否则将j分配给bar。这是因为三元运算符中的问号也是一个有效的序列点。
C99标准(附录C)中列出的所有序列点如下:
以下是5.1.2.3中描述的序列点:
— 调用函数后,在评估参数之后(6.5.2.2)。
— 以下运算符第一个操作数的末尾:逻辑AND &&(6.5.13); 逻辑OR ||(6.5.14);条件?(6.5.15);逗号,(6.5.17)。
— 完整声明符的末尾:声明符(6.7.5);
— 完整表达式的末尾:初始化程序(6.7.8);表达式语句中的表达式 (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.19.6、7.24.2)。
— 在每次调用比较函数之前和之后,以及在调用比较函数和任何移动对象之间的任何位置 传递作为该调用参数的对象(7.20.5)。
这实质上意味着任何不跟随序列点的表达式都可能引发未定义行为,例如:
printf("%d, %d and %d\n", i++, i++, i--);

在这个语句中,适用的序列点是“在参数求值之后调用函数”。 在参数求值之后。如果我们再看一下语义,在同样的标准6.5.2.2下的第十点,我们可以看到:
10 函数指示符、实际参数和实际参数内的子表达式的评估顺序未指定,但在实际调用之前存在一个序列点。
这意味着对于i = 1,传递给printf的值可能是:
1, 2, 3//left to right

但同样有效的是:
1, 0, 1//evaluated i-- first
//or
1, 2, 1//evaluated i-- second

你可以确定的是,在此调用之后,i 的新值将为2。但上述所有值理论上都是同样有效的,并且符合100%的标准。
但是关于未定义行为的附录明确列出了这也是调用未定义行为的代码:
在两个序列点之间,一个对象被修改了超过一次,或者被修改并且读取其先前的值,而不是为了确定要存储的值(6.5)。
理论上,你的程序可能会崩溃,而不是打印1、2和3,也可能输出"666, 666 and 666"

1

由于C标准未规定参数求值的顺序,每个编译器实现都可以采用任意一种顺序。这就是为什么编写类似foo(i++)这样的代码是完全疯狂的原因 - 当切换编译器时,您可能会得到不同的结果。

还有一个重要的事情没有在这里强调 - 如果您喜欢的ARM编译器按从左到右的顺序评估参数,则它将对所有情况和所有后续版本都执行此操作。对于编译器来说,参数的读取顺序只是一种惯例...


你如何从不同的编译器中获得 foo(i++) 不同的结果?这是一个完全定义良好的函数调用,因此只有错误的编译器才会与其他编译器给出不同的结果。 - Jonathan Leffler
实际上不是这样的:如果编译器从右到左读取参数,它将首先读取一元运算符,然后再读取参数,就像执行++i;foo(i);一样。如果编译器从左到右读取,则效果将是foo(i);i++。编译器并没有出错,但传递参数和评估表达式是两回事。我已经看到过不止一次这种情况发生。 - Pandrei
1
@pandrei:这完全是胡说八道,foo(i++) 是完全可预测的。i 的值被设置到一边,传递到 foo,并且在调用之后的时间给 i 赋值为 i+1。它在标准中描述:此处序列点是“函数调用后,在参数已经被评估之后”。你正在序列点之间修改一个对象一次,并且你正在进行后自增 i,所以其行为是完全定义的。如果你的编译器对此进行了“优化”成 foo(++i),那么我会说你的编译器有严重的问题。 - Elias Van Ootegem
@EliasVanOotegem: 尽管我非常同意这种行为是完全被定义的,但是在调用函数之前与之相关的有一个序列点。 ISO / IEC 9899:2011 §6.5.2.2 函数调用表示_¶10 在评估函数指示器和实际参数之后但在实际调用之前存在一个序列点._ C99使用了以下措辞:函数指示符、实际参数以及实际参数中的子表达式的评估顺序未指定,但是在实际调用之前存在一个序列点。 - Jonathan Leffler
@JonathanLeffler:这就是我说后置递增运算符在这里无法优化的原因,而且i的初始值需要被设置。我用int i, j = 123; i = foo(j++); printf("i = %d, j=%d\n", i, j);进行了测试,其中foo只是执行printf("value passed: %d\n", x); return x;将其接收到的值分配给i。输出结果为:Value passed 123 和 _i = 123, j=124_,正如我们所期望的一样。但是我应该说,在这个答案的示例中,i++是在值被设置并传递给foo之后递增的。 - Elias Van Ootegem

1

最终我找到了答案...是的。 这是因为参数在评估后传递。因此,传递参数与评估完全不同。由于C编译器通常构建以最大化速度和优化为目标,可以以任何方式评估表达式。
因此,参数传递和评估都是完全不同的故事。


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