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

57

这段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草案,在前一个和下一个序列点之间,一个对象在表达式的求值过程中最多只能被修改一次。此外,先前的值只能被读取以确定要存储的值。它没有提到读取一个对象以确定要修改的对象本身。因此,这个语句可能产生未定义的行为。然而,我感觉很奇怪。这实际上会产生未定义的行为吗?(我还想了解其他ISO C版本中的这个问题。)

1
不,它本身并不是一个表达式。而且只会在读取两次后进行一次修改。 - B. Nadolson
7
什么不是表达式?为什么a[a[0]] = 1;会读取两次a[0]?它似乎只读取一次a[0] - Masaki Hara
那么 int index = a[0]; a[index] = 1; 怎么样?然后,你还可以通过将第二个语句替换为 if(index<(sizeof(a)/sizeof(int))) a[index] = 1; 来确保索引在数组的范围内。 - Daniel M.
为什么不尝试运行它? - Devesh Khandelwal
6
不行,仅仅运行并查看输出在这种情况下是不够的。 - haccks
显示剩余6条评论
5个回答

52
先前的值只能被读取用于确定将要存储的值,这有点模糊并引起了混乱,部分原因是C11取消了它并引入了新的顺序模型。它试图表达的意思是:如果保证在写入新值之前阅读旧值比较早,则可以接受,否则就是未定义行为(UB)。当然,在写入新值之前计算新值是必需的。例如,`x = x + 5`是正确的,因为不知道`x`的值就无法计算出`x+5`的结果。但是`a[i] = i++`是错误的,因为左侧赋值语句中的对`i`的读取不需要为了计算存储在`i`中的新值。现在回到您的代码。我认为这是良好定义的行为,因为为了确定数组索引而读取`a[0]`是在写入之前保证发生的。我们不能写入直到确定了写入位置。而我们在读取`a[0]`之前并不知道要写入哪里,因此读取必须在写入之前发生,所以没有UB。有人评论了序列点。在C99中,这个表达式中没有序列点,因此序列点不涉及这个讨论。

2
从记忆中得知 - 在C11中,操作数的计算在执行赋值之前顺序化,因此在那里不会产生未定义行为。 - M.M
谢谢!我理解答案的结论是ISO C99规范存在一些错误,应该进行适当的解释。你的答案似乎基于对ISO C标准的深刻理解,所以我会认同ISO C99存在错误的结论。 - Masaki Hara
5
ISO C99 没有关于这个错误的问题,只是该语句有点模糊。 - haccks
2
我认为你错了。从引文中可以清楚地看出,a[a[0]] = 1确实会引发未定义的行为。如果一个人假定CPU指令的执行是严格顺序的,并且在下一条指令开始执行之前,所有指令的副作用(包括电路上的瞬态过程)都已经完成,那么这种行为可能看起来毫无意义。这适用于现代主流架构。然而,也有尝试开发超标量架构的努力,其中可能不会这样。 - ach
5
如果C99中的声明是打算表达你所说的意思,那么它比“有点模糊”更糟糕,因为它存在缺陷,不授权其打算授权的所有内容。“确定要存储的值”并不含糊,无论是否包括“确定要存储值的位置”,都不包括它。而且,C11的作者似乎普遍认为C99错了。另一方面,如果编译器编写者普遍按照你所说的解释它,那么我们至少拥有了实际保证,这个保证比C99的作者们实际写下来的要强大 :-) - Steve Jessop
显示剩余6条评论

17

这段C99代码是否会产生未定义行为?

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

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

一个对象可以被多次读取以修改自身,这是完全定义好的行为。看这个例子:

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 中的值。


In C11标准中,这一点已经改变了。
6.5表达式:
如果对标量对象的副作用相对于同一标量对象的不同副作用或使用同一标量对象的值计算是未排序的,则行为是未定义的。[...]
在表达式a[a[0]] = 1中,只有一个副作用作用于a[0],并且索引a[0]的值计算在a[a[0]]的值计算之前被排序。

2
这是最好的答案,因为它是唯一一个提到序列点的答案。我认为其他人没有意识到“只有一种逻辑顺序可以评估”和“在两个序列点之间仅修改一次,因此不是UB”的区别。我见过很多序列点违规(当然是UB),它们似乎只有一个合理的数学解释。 - Evan Teran
1
当然,序列点很重要。我很高兴看到有人提到了序列点。然而,“仅修改一次”是不够的。例如,j = i + (i = 2); 是未定义的(我认为)。问题在于允许同时修改和读取同一对象。 - Masaki Hara
4
标准规定:阅读先前的值以确定要存储的值是可以的。但是,没有提到可以阅读先前的值以确定对象本身 - Masaki Hara
2
@haccks,是的,这就是为什么你在回答中提到的示例表达式具有定义行为。但是对于OP的表达式来说并非如此。 - John Bollinger
@JohnBollinger; 我增加了一些解释。 - haccks
显示剩余3条评论

13

C99在附录C中列举了所有的序列点。其中一个位于末尾。

a[a[0]] = 1;

这是一个完整的表达式语句,但其中没有序列点。虽然逻辑上指出子表达式a [0]必须首先计算,结果用于确定将值分配给哪个数组元素,但序列规则并不确保它。当a [0]的初始值为0时,在两个序列点之间读取和写入了a [0],而读取不是为了确定要写入什么值。根据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;这是怎么回事? - Marc van Leeuwen
@MarcvanLeeuwen:C标准认为lvalue+=2;等同于lvalue=lvalue+2;但是只有在确定lvalue时的任何副作用仅执行一次;我预计C++也会类似。 - supercat
@supercat:是的,C++ 也有这个问题。我的观点是,如果 lvalue=lvalue+2 由于双重副作用而具有未定义行为,那么这个短语就是在说 lvalue+=2 的行为等同于未定义行为(因此本身是未定义的),只是未定义行为的原因被消除了。对我来说,这并没有指定任何明确的行为。主要的问题是,说 x 等同于 y,只是某些细节 z 不同,这是一种非常糟糕的指定 x 的方式,特别是当 y 是未定义的时候。 - Marc van Leeuwen
@MarcvanLeeuwen:我不明白你在抱怨什么。如果lvalue = lvalue + 2;的行为已经被定义,但由于副作用发生了两次而无法实现,那么为什么不能防止双重副作用并使行为得到定义呢? - supercat
@supercat 因为未定义行为意味着根本没有定义。如果我们解除UB的禁令,下面并没有完全定义的行为可以恢复;因此,“只要”没有任何意义,正式地说。人类可以猜测意图,并尝试理解语句的执行方式,如果只是尝试从描述中删除双重副作用(但其中哪一个?),但正式上它毫无意义。这就是为什么我在我的第一条评论中说“经常吸引人们对意图的理解”的原因。 - Marc van Leeuwen
显示剩余2条评论

5
该值是明确定义的,除非 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; - Masaki Hara
3
任何形如 a[b] 的有效表达式,在计算 a[b] 值之前,必须先计算表达式 a 和表达式 b。这种逻辑是递归的。 - Peter
4
“仅有这种评估方法”并不意味着代码没有被定义。标准中已经清楚地说明了哪些是未定义的。引文中的“应当”一词表示如果约束条件未定义,则行为也是未定义的。我的问题是,为什么根据标准,代码仍然可以是有效的。 - Masaki Hara
1
@Peter:从其他答案中的阅读,我认为有一个相当有说服力的论点,即C99没有用足够强烈的措辞,这种情况可能在技术上是未定义的行为。除非编译器是故意恶意的,否则只有使任何意义(在使用索引之前评估它)的行为。这就是为什么在实践中,这不是需要担心的事情,其他答案也已经说过了。如果我没记错,“未定义的行为”意味着可以允许发生任何事情,这可能会允许恶意编译器违反需要明显排序的其他规则。 - Peter Cordes
2
@Peter,在此情况下具有未定义行为是代码的特征,而不是执行它的情况的功能。实践中您可以期望编译器生成符合预期的代码,这是无关紧要的。符合规范的编译器可以生成任何东西的代码,例如将“shame on you!”打印到stderr,作为评估表达式的行为。尽管这可能不受欢迎,但它不会因此而不符合规范。 - John Bollinger
显示剩余11条评论

1

副作用包括修改对象1

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

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

a[a[0]] = 1

然而,在赋值运算符中,标准中的文本解释了操作符=左右两个操作数的值计算在修改左操作数之前进行3
因此,该行为被定义为第一条规则1不会被违反,因为修改(副作用)在同一对象的值计算之后进行。

1(摘自ISO/IEC 9899:201x 5.1.2.3程序执行2):
访问易失性对象、修改对象、修改文件或调用执行任何这些操作的函数都是副作用,即更改执行环境状态。

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

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


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