这是否是在C语言中的未定义行为?如果不是,请逻辑地预测输出。

30

代码1

#include <stdio.h>
int f(int *a, int b) 
{
  b = b - 1;
  if(b == 0) return 1;
  else {
    *a = *a+1;

    return *a + f(a, b);
  }
}

int main() {
  int X = 5;
  printf("%d\n",f(&X, X));
}

考虑下面的 C 代码。问题是预测输出结果。从逻辑上讲,我得到了 31 作为输出。(机器输出

当我将返回语句更改为

return f(a, b) + *a;

我逻辑上得到了37。(机器输出

我的一个朋友说,在计算返回语句时:

return *a + f(a, b);

我们在树的深度遍历过程中计算a的值,即*a先被计算,然后调用f(a, b)函数,而在...

return f(a,b) + *a;

返回时解决,即先计算f(a,b),然后调用*a

采用这种方法,我尝试自己预测以下代码的输出:

代码2

#include <stdio.h>
int foo(int n) 
{
    static int r;
    if(n <= 1)
        return 1;

    r = n + r;
    return r + foo(n - 2);
} 

int main () {
   printf("value : %d",foo(5));
}

对于 return(r+foo(n-2));

enter image description here

从逻辑上讲,我得到14作为输出结果 (在机器上的输出结果)

对于 return(foo(n-2)+r);

enter image description here

我得到17作为输出结果。(在机器上的输出结果)

然而,当我在我的系统上运行代码时,我在两种情况下都得到17。

我的问题:

  • 我朋友给出的方法是否正确?
  • 如果是,为什么我在机器上运行代码2时得到相同的输出?
  • 如果不是,请问解释代码1代码2的正确方法是什么?
  • 是否存在未定义的行为,因为C不支持传递引用?尽管可以使用指针实现它,但它在代码1中被使用?

简而言之,我只想知道如何正确预测上述4种情况的输出。


1
对于10K个用户:与给定的C代码如何工作?密切相关,但不同于现已删除的问题 - 由另一个用户提出。 - Jonathan Leffler
5
由于return *a + f(a, b);(和return f(a, b) + *a;)中各项的计算顺序未定义,并且该函数修改了a指向的值,因此您的代码具有未定义的行为,任何答案都是可能的。请注意不要改变原文的意思。 - Jonathan Leffler
2
如果操作是(a + b),它取决于编译器(而不是语言)先评估a还是b;语言对这些术语的评估顺序没有任何要求。 - Jonathan Leffler
1
@DavidBowling: 不行,因为你无法确定在函数调用之前还是之后评估*a,因此你无法确定将添加什么值到调用函数的结果中。 - Jonathan Leffler
2
@JonathanLeffler:编译器要求对*a进行评估并调用函数f(),或先调用f()再评估*a。编译器不需要以任何一致或可预测的方式在这些选项中进行选择,但如果代码引用了未定义行为,则不允许完全任意地运行。 - supercat
显示剩余5条评论
3个回答

18

代码 1

对于代码 1来说,在return *a + f(a, b);(以及return f(a, b) + *a;)中术语的求值顺序没有被C标准指定,而函数修改了a指向的值,因此你的代码具有未指定的行为,可能会得到不同的答案。

正如评论区的激烈讨论所示,“未定义的行为”、“未指定的行为”等术语在C标准中具有技术含义,本答案早期版本错误地使用“未定义的行为”代替了“未指定的行为”。

问题的标题是“这在C中是未定义的行为吗?”,回答是“不是;它是未指定的行为,而不是未定义的行为”。

代码 2 — 经修正

对于经过修正的代码 2,该函数也具有未指定的行为:递归调用会改变静态变量r的值,因此对求值顺序的更改可能会导致结果不同。

代码 2 — 修正前

对于代码 2最初显示的int f(static int n) { … },代码不会(或者至少不应该)编译。在函数参数的定义中,唯一允许出现的存储类是register,因此static的存在应该会导致编译错误。

  

ISO/IEC 9899:2011 §6.7.6.3 函数声明符(包括原型)   ¶2 参数声明中唯一允许出现的存储类说明符是register

在macOS Sierra 10.12.2上使用GCC 6.3.0进行编译,如下所示(请注意,没有请求额外的警告):

$ gcc -O ub17.c -o ub17
ub17.c:3:27: error: storage class specified for parameter ‘n’
 int foo(static int n)
                    ^

不,如所示它根本无法编译 - 至少,我使用现代版本的GCC无法编译。

然而,假设这个问题被解决了,那么这个函数也存在未指定的行为:静态变量r的值会被递归调用改变,因此对评估顺序的更改可能会改变结果。


2
第一个示例已经定义。请查看我的答案。https://dev59.com/6FgR5IYBdhLWcg3wEZ2V#41777679 - 2501
我刚刚编辑了问题,将代码2中的错误去除,以消除由存储类引起的错误。 - user5863049
3
你确定它是未定义的吗?我会预期是未指定的(使用gcc从右到左评估,MSVC从左到右评估),但不是未定义的,因为我认为函数调用在评估左右操作数之间引入了序列点(无论顺序如何)。 - Matthieu M.
1
@MatthieuM。这还没有具体说明。我认为他很快会纠正它的。 - haccks
@MatthieuM;在GCC中从右向左计算,在MSVC中从左向右计算:你是从哪里得到这个信息的? - haccks
@haccks:经验。有日期的经验。希望是有效的。在2008/2009年,我使用gcc 3.4.2和Visual Studio 2003编译代码,我记得有几次意识到它们在评估顺序上有所不同。我从未遇到过任何编译器的交错评估问题,但自那以后我一直非常谨慎,倾向于避免依赖于评估顺序... - Matthieu M.

13

C语言标准规定:

6.5.2.2/10 函数调用:

在实际调用之前,函数名和实参表达式的计算都有一个顺序点。在调用函数体之前,调用函数中的所有(包括其他函数调用)未经显式排序的计算都是不确定排序1的。94)

脚注86(第6.5 / 3节)提到:

在程序执行期间被评估多次的表达式中,其子表达式的不排序和不确定排序的评估在不同评估中不需要一致地进行

在表达式 return f(a,b) + *a;return *a + f(a,b); 中,对于子表达式 *a 的求值是不确定排序的。在这种情况下,对于同一个程序可能会得到不同的结果。
请注意,在上述表达式中对 a 的副作用是有序的,但是它的顺序是不确定的。


1. 当 A 在 B 之前或之后排序时,评估A和B是不确定排序的,但其顺序未指定。(C11- 5.1.2.3/3)


2
@pC_ - 不一定。ab都不一定会先被计算。它们的计算可能是交错进行的。 - Robᵩ
4
@pC_: 优化设置、编译器版本、月相和它是否喜欢你的用户ID都可能会改变顺序。实际上,对于相同的编译器版本和优化设置,通常情况下计算顺序是一致的,但如果表达式ab过于复杂(比如说它们是*c[i++] + *d[--j]这样的形式),那么计算顺序就不太好预测了——编译器可能会按照与简单的a+b不同的顺序计算它们。 - Jonathan Leffler
3
在C99语言中,在函数调用之前和之后存在一个序列点,因此在 *a 的评估和对同一对象的修改之间不会发生没有序列点的情况。尽管C11使用了不同的语言,但它在逻辑上等效。 - M.M
4
@haccks 在 ff 的参数被评估之后,存在一个序列点。然后执行 f 主体中的语句,并且这些语句与 return 表达式的其余部分是不确定顺序的。(换句话说,*a 要么在所有主体语句之前,要么在所有主体语句之后) - M.M
@M.M;我改变了主意。你是对的。我一开始是按照运算符+的方式思考,这种情况下其操作数的顺序是无序的,但是在函数调用中则不同。感谢你指出来。 - haccks
显示剩余7条评论

8

我将专注于第一个例子的定义。

第一个例子是使用未指定的行为来定义的。这意味着有多个可能的结果,但行为不是未定义的。(如果代码可以处理这些结果,那么行为是被定义的。)

未指定行为的一个简单例子是:

int a = 0;
int c = a + a;

在这种情况下,无法确定是先评估左边的a还是右边的a,因为它们是"未排序的"unsequenced+ 运算符没有指定任何序列点1。有两种可能的顺序,要么先评估左边的a再评估右边的a,要么反过来。由于两边都没有被修改2,行为是定义明确的。


如果左边的a或右边的a在没有序列点(即unsequenced)的情况下被修改,则行为将是未定义的2

int a = 0;
int c = ++a + a;

如果在左边或右边加入序列点,那么左边和右边将被视为具有不确定的顺序。这意味着它们是有顺序的,但未指定哪个先被评估。行为将被定义。请注意,逗号运算符引入了一个序列点。
int a = 0;
int c = a + ((void)0,++a,0);

有两种可能的顺序。
如果先评估左侧,则a的值为0。然后评估右侧。首先评估(void)0,然后是一个序列点。接着a被增加,再是一个序列点。然后0被评估为0,并添加到左侧。结果为0。
如果先评估右侧,则评估(void)0,然后是一个序列点。接着a被增加,再是一个序列点。然后0被评估为0。然后评估左侧,a的值为1。结果为1。
你的例子属于后一种情况,因为操作数的顺序是不确定的。函数调用起到与上面例子中逗号运算符相同的作用。你的例子很复杂,所以我将使用我的例子,它也适用于你的例子。唯一的区别是在你的例子中有许多更多的可能结果,但推理是相同的。
void Function( int* a)
{
    ++(*a);
    return 0;
}
int a = 0;
int c = a + Function( &a );
assert( c == 0 || c == 1 );

有两种可能的顺序。
如果先评估左侧,a将评估为0。然后评估右侧,出现一个序列点并调用函数。然后a被递增,接着是由完整表达式6引入的另一个序列点,其结尾由分号表示。然后返回0并加上0。结果为0。
如果先评估右侧,则出现一个序列点并调用函数。然后a被递增,接着是由完整表达式的结尾引入的另一个序列点。然后返回0。然后评估左侧,a评估为1并添加到0。结果为1。
(摘自:ISO/IEC 9899:201x) 1(6.5表达式3)
除非另有规定,否则子表达式的副作用和值计算未排序。 2(6.5表达式2)
如果标量对象上的副作用相对于同一标量对象的不同副作用或使用同一标量对象的值计算未排序,则行为未定义。 3(5.1.2.3程序执行)
当A在B之前或之后排序时,Evaluations A和B的排序不明确。 4(6.5.17逗号运算符2)
逗号运算符的左操作数被评估为void表达式;在其评估和右操作数之间有一个序列点。 5(6.5.2.2函数调用10)
在函数设计器和实际参数的计算之后但实际调用之前,有一个序列点。 6(6.8语句和块4)
在完整表达式的评估和要评估的下一个完整表达式的评估之间存在序列点。

1
因此,表达式a((void)0,++a,0)是未排序的,但第二个表达式中序列点的存在保证了,虽然(void)0的评估可能与a的评估交错进行,但++a0的评估必须在(void)0的评估之后完成。如果第二个表达式改为((void)0,++a),则这将是未定义的行为,因为如果在a之前评估此表达式,则++aa之间没有序列点。我理解得对吗? - ad absurdum
1
@DavidBowling 是的,那完全正确。可能的顺序之一会导致未定义行为(6.5 §2),而另一个则不会,并且整个表达式由于以下原因是未定义的:如果一个表达式的子表达式有多种允许的顺序,且在任何一种顺序中发生了无序副作用,则行为就是未定义的。 - 2501
1
谢谢。这是非常有用的解释。 - ad absurdum
1
@haccks 你误读了David的写法。他在技术上是错误的,但他的意思是如果没有逗号它们将是无序的。逗号运算符引入了一个序列点,是的,有了它它们就是不确定顺序的。 - 2501
@2501;糟糕!我的错。删除我的评论。 - haccks
1
我应该写成:"...a((void)0, ++a, 0)是不确定顺序的..."。 - ad absurdum

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