a[a[0]] = 1 会产生未定义的行为吗?

Posted

技术标签:

【中文标题】a[a[0]] = 1 会产生未定义的行为吗?【英文标题】:Does a[a[0]] = 1 produce undefined behavior? 【发布时间】:2015-11-16 02:38:20 【问题描述】:

此 C99 代码是否会产生未定义的行为?

#include <stdio.h>

int main() 
  int a[3] = 0, 0, 0;
  a[a[0]] = 1;
  printf("a[0] = %d\n", a[0]);
  return 0;

在语句 a[a[0]] = 1; 中,a[0] 被读取和修改。

我查看了 ISO/IEC 9899 的 n1124 草案。它说(在 6.5 表达式中):

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

它没有提到读取对象以确定要修改的对象本身。因此,此语句可能会产生未定义的行为。

但是,我觉得这很奇怪。这实际上会产生未定义的行为吗?

(我也想知道其他ISO C版本的这个问题。)

【问题讨论】:

没有。它本身不是一种表达方式。而且它只修改一次,读了两次。 什么不是表达式?为什么a[a[0]] = 1; 两次读取a[0]?好像读过一次a[0] int index = a[0]; a[index] = 1; 怎么样?然后,您还可以通过将第二条语句替换为 if(index&lt;(sizeof(a)/sizeof(int))) a[index] = 1; 来确保索引在数组的范围内 为什么不试试运行呢? @DeveshKhandelwal;不。在这种情况下,仅运行并查看输出无济于事。 【参考方案1】:

应只读先前的值以确定要存储的值。

这有点含糊,会引起混乱,这也是 C11 将其抛弃并引入新的测序模型的部分原因。

它想说的是:如果保证读取旧值比写入新值更早发生,那很好。否则就是UB。当然,要求在写入之前计算新值。

(当然我刚才写的描述会被一些人认为比标准文本更模糊!)

例如x = x + 5 是正确的,因为在不知道x 的情况下不可能计算出x + 5。但是a[i] = i++ 是错误的,因为不需要读取左侧的i 即可计算出存储在i 中的新值。 (i 的两次读取单独考虑)。


现在回到您的代码。我认为这是定义明确的行为,因为为了确定数组索引而读取a[0] 保证在写入之前发生。

在我们确定在哪里写之前,我们不能写。在我们阅读a[0] 之前,我们不知道在哪里写。因此读必须在写之前,所以没有UB。

有人评论了序列点。在C99中,这个表达式中没有序列点,所以序列点不参与讨论。

【讨论】:

在我的头顶上 - 在 C11 中,评估操作数是 在执行分配之前排序的,所以它不存在 UB。 谢谢!我理解答案的结论是ISO C99规范有一点错误,应该适当解释。您的回答似乎是基于对 ISO C 标准的深刻理解,所以我会承认 ISO C99 有错误的结论。 @MasakiHara; ISO C99 对此没有任何错误,只是声明有点模糊。 我认为你错了。从引用中可以清楚地看出a[a[0]] = 1 确实调用了未定义的行为。如果假设一个指令的所有副作用(包括电子电路上的瞬态过程)在下一条指令开始执行之前完成,那么这可能看起来毫无意义。这适用于现代主流架构。然而,也有人尝试开发可能并非如此的超标量架构。 IMO 如果 C99 中的陈述意在表达您所说的意思,那么它比“有点模糊”更糟糕,这是有缺陷的,因为它没有授权它打算授权的一切。 “确定要存储的值”对于它是否包括“确定存储值的位置”并不含糊:它不包括它。 C11 的作者似乎普遍同意 C99 是错误的。 OTOH,如果编译器编写者按照您所说的那样普遍解释它,那么我们至少有一个比 C99 的作者实际写下来的更强大的事实保证:-)【参考方案2】:

此 C99 代码是否会产生未定义的行为?

没有。它不会产生未定义的行为。 a[0] 在两个 sequence points 之间只修改一次(第一个序列点在初始化器 int a[3] = 0, 0, 0; 的末尾,第二个在完整表达式 a[a[0]] = 1 之后)。

它没有提到读取对象以确定要修改的对象本身。因此,此语句可能会产生未定义的行为。

一个对象可以被多次读取以修改其自身及其完美定义的行为。看这个例子

int x = 10;
x = x*x + 2*x + x%5;   

引用的第二个陈述说:

此外,先验值应只读以确定要存储的值。

读取上述表达式中的所有x以确定对象x本身的值。


注意:请注意,问题中提到的引用有两部分。第一部分说:在前一个序列点和下一个序列点之间,一个对象的存储值最多只能通过表达式的评估修改一次。和 因此像

这样的表达
i = i++;

属于 UB(前一个和下一个序列点之间的两个修改)。

第二部分说:另外,先读取的值应该是只读的,以确定要存储的值。,因此像

这样的表达式
a[i++] = i;
j = (i = 2) + i;  

调用 UB。在这两个表达式中,i 在前一个序列点和下一个序列点之间只修改一次,但最右边的i 的读数并不能确定要存储在i 中的值。


在 C11 标准中,这已更改为

6.5 表达式:

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

在表达式a[a[0]] = 1中,a[0]只有一个副作用,索引a[0]的值计算在a[a[0]]的值计算之前排序。

【讨论】:

这是最好的答案,因为它是唯一一个甚至提到序列点的答案。我觉得其他人没有认识到“只有一个可以评估的逻辑顺序”和“它在两个序列点之间只修改一次,因此不是 UB”之间存在差异。我见过很多似乎只有一种合理的数学解释的序列点违规(当然是 UB) 当然序列点就是重点。我很高兴看到一个提到序列点的答案。然而,“只修改一次”是不够的。例如,j = i + (i = 2); 是未定义的(我认为)。问题是何时允许修改和读取同一个对象。 标准说:读取先验值确定要存储的值是可以的。但是,没有提到读取先验值确定对象本身 @MasakiHara;表达式j = i + (i = 2); 也调用UB。但是,要理解这一点,您必须查看您在回答中引用的部分的第二部分:此外,应仅读取先验值以确定要存储的值。,读取第一个i 的值不确定要存储在i 中的值。 ii = 2 修改。 @hacks,是的,这就是您的示例表达式定义行为的原因,正如您在回答中提到的那样。但 OP 的表达方式并非如此。【参考方案3】:

C99 列出了附件 C 中所有序列点的枚举。在末尾有一个

a[a[0]] = 1;

因为它是一个完整的表达式语句,但是里面没有序列点。尽管逻辑规定必须首先评估子表达式a[0],并且结果用于确定将值分配给哪个数组元素,但排序规则并不能确保这一点。当a[0]的初始值为0时,a[0]在两个序列点之间都是读写的,读not是为了确定写什么值。因此,根据 C99 6.5/2,评估表达式的行为是未定义的,但实际上我认为您无需担心。

C11 在这方面更好。第 6.5 节第 (1) 段说

表达式是一系列运算符和操作数,用于指定值的计算,或指定对象或函数,或产生副作用,或执行它们的组合。运算符的操作数的值计算在运算符结果的值计算之前排序。

特别注意第二句,它在 C99 中没有类似物。你可能认为这就足够了,但事实并非如此。它适用于值计算,但它没有说明与值计算相关的副作用的顺序。更新左操作数的值是一个副作用,因此额外的句子不直接适用。

C11 仍然为我们提供了这一点,因为赋值运算符的规范提供了所需的排序 (C11 6.5.16(3)):

[...] 更新左操作数的存储值的副作用是 在左右操作数的值计算之后排序。操作数的计算是无序的。

(相比之下,C99 只是说更新左操作数的存储值发生在前一个和下一个序列点之间。)然后,将 6.5 节和 6.5.16 节放在一起,C11 给出了一个明确定义的序列:内部[] 在外部 [] 之前评估,在更新存储值之前评估。这满足 C11 的 6.5(2) 版本,因此在 C11 中,定义了计算表达式的行为。

【讨论】:

虽然 C++ 标准在这方面比 C 有所改进,但它也经常受到(有限的)人类对意图的理解(如短语“确定要存储的值”)而不是到正式模型。以看起来完美定义的a[++i]+=2 为例。然而,C++ 标准说 [expr.ass] 的行为等同于 a[++i]=a[++i]+2 的行为(它具有明显未定义的行为),除了 ++i 只评估一次(这删除了 ​​UB 的来源)。所以行为等价于UB,只是没有UB;怎么样? @MarcvanLeeuwen:C 标准认为lvalue+=2; 等同于lvalue=lvalue+2;除了确定左值的任何副作用只执行一次;我希望 C++ 确实是类似的。 @supercat:是的,C++ 也有这个。我的观点是,如果lvalue=lvalue+2 具有未定义的行为由于双重副作用,那么这句话是说lvalue+=2 的行为等同于未定义的行为(因此本身未定义),除了原因未定义的行为被删除。对我来说,没有指定任何明确的行为。除了某些细节 z 不同之外,说 x 等价于 y 的要点是指定 x 的一种非常糟糕的方式,尤其是在 y 未定义的情况下。 @MarcvanLeeuwen:我看不出你在抱怨什么。如果 lvalue = lvalue + 2; 的行为会被定义,但由于副作用发生了两次,为什么不防止双重副作用离开定义的行为? @supoercat 因为未定义的行为意味着根本没有定义。如果我们解除对 UB 的禁令,似乎并没有一个完美定义的行为可以恢复;因此,正式地,“将被定义,但事实上”是没有意义的。人类可以猜测意图是什么,并尝试理解如果只是尝试从描述中删除双重副作用(但两者中的哪一个?),语句的执行将是什么,但从形式上讲,它没有任何意义。这就是为什么我在第一条评论中说“经常吸引人类对意图的理解”。【参考方案4】:

该值定义明确,除非a[0] 包含的值不是有效的数组索引(即在您的代码中不是负数且不超过3)。您可以将代码更改为更具可读性和等效性

 index = a[0];
 a[index] = 1;    /* still UB if index < 0 || index >= 3 */

在表达式a[a[0]] = 1 中,必须先计算a[0]。如果a[0] 恰好为零,则a[0] 将被修改。但是编译器(不符合标准)无法在尝试读取其值之前更改评估顺序并修改a[0]

【讨论】:

我同意不能以其他方式正常解释代码。但是,我找不到标准的证据。 index = a[0]; a[index] = 1; 无疑是有效的,但我不确定 a[a[0]] = 1 是否等同于 index = a[0]; a[index] = 1; 是的。给定a[b] 形式的任何有效表达式,必须先计算表达式a 和表达式b,然后才能计算a[b]。该逻辑是递归的。 “没有其他方法可以评估”并不意味着代码不是未定义的。未定义的内容在标准中单独说明。引文中的“shall”一词(见上述问题)表示如果约束未定义,则行为未定义。我的问题是为什么代码仍然可以根据标准有效。 @Peter:通过阅读其他答案,我认为有一个非常有说服力的论点,即 C99 的措辞不够强烈,这种情况在技术上可能是未定义的行为。但是,除非编译器是故意恶意的,否则只有行为才有意义(在使用之前评估索引)。这就是为什么在实践中这不是值得担心的事情,其他答案也说过。 IIRC,“未定义的行为”实际上是指允许发生任何事情,这可能允许恶意编译器违反其他需要明显排序的规则。 @Peter,在这种情况下,具有未定义的行为是代码的一个特征,而不是在任何情况下执行它的函数。实际上,您可以期望编译器生成执行预期操作的代码是无关紧要的。符合标准的编译器可以生成可以执行任何操作的代码,例如打印“shame on you!”到stderr,作为评估表达式的行为。它不会因为这个原因不符合要求,尽管它可能不受欢迎。【参考方案5】:

副作用包括对象的修改1

C 标准规定,如果对对象的副作用与对同一对象的副作用或使用同一对象的值的值计算未排序,则行为未定义2

此表达式中的对象a[0] 被修改(副作用),它的值(值计算)用于确定索引。看起来这个表达式会产生未定义的行为:

a[a[0]] = 1

然而,标准中赋值运算符中的文本解释说,运算符=的左右操作数的值计算是在修改左操作数之前排序的3。 p>

因此定义了行为,因为没有违反第一条规则1,因为修改(副作用)是在同一对象的值计算之后排序的。


1(引自 ISO/IEC 9899:201x 5.1.2.3 Program Exectution 2): 访问 volatile 对象、修改对象、修改文件或调用函数 执行这些操作中的任何一个都是副作用,它们是状态的变化 执行环境。

2(引自 ISO/IEC 9899:201x 6.5 表达式 2): 如果标量对象上的副作用相对于不同的副作用是无序的 在同一标量对象或使用同一标量的值的值计算上 对象,行为未定义。

3(引自 ISO/IEC 9899:201x 6.5.16 赋值运算符 3): 更新左操作数的存储值的副作用是 在左右操作数的值计算之后排序。的评价 操作数未排序。

【讨论】:

以上是关于a[a[0]] = 1 会产生未定义的行为吗?的主要内容,如果未能解决你的问题,请参考以下文章

未定义行为与求值顺序

动态数组的惯用初始化是不是会调用未定义的行为?

如果 a 未初始化,a^a 或 a-a 是未定义的行为吗?

这是未定义的行为吗

以下链式赋值是不是会导致未定义的行为?

未定义的行为是否真的有助于现代编译器优化生成的代码?