在C语言中,数组下标的求值顺序(相对于表达式)是怎样的?

47

看着这段代码:

static int global_var = 0;

int update_three(int val)
{
    global_var = val;
    return 3;
}

int main()
{
    int arr[5];
    arr[global_var] = update_three(2);
}

哪个数组项会被更新?是0还是2?

在C语言规范中是否有指定这种情况下操作的优先级的部分?


21
这似乎存在未定义行为。肯定不应该有意编写这样的代码。 - Fiddling Bits
4
一些轶事性的结果:https://godbolt.org/z/hM2Jo2 - Bob__
15
这与数组索引或操作顺序无关,而是与C规范所称的“序列点(sequence points)”有关。尤其是,赋值表达式在左右表达式之间不会创建序列点,因此编译器可以自由选择执行顺序。 - Lee Daniel Crocker
4
你应该向 clang 报告一个功能请求,以便这段代码触发一个警告,在我看来。 - malat
1
@TonyK 编译器已经将深入考虑是否以及如何内联此函数的工作完成了。 - Daniel McLaury
显示剩余5条评论
5个回答

52

左右操作数的顺序

在执行arr[global_var] = update_three(2)中的赋值操作时,C语言实现必须评估操作数,并作为副作用更新左操作数的存储值。 C 2018年6.5.16(有关分配的部分)第3段告诉我们,在左和右操作数中没有排序:

操作数的评估是未排序的。

这意味着C语言实现可以先计算lvaluearr [global_var] (通过“计算lvalue”,我们指弄清楚此表达式指的是什么),然后评估update_three(2),最后将后者的值赋给前者; 或者首先评估update_three(2),然后计算lvalue,然后将前者赋给后者; 或以某种交织的方式评估lvalue和update_three(2),然后将右值分配给左lvalue。

在所有情况下,将值分配给lvalue必须最后进行,因为6.5.16 3还表示:

…更新左操作数的存储值的副作用在左和右操作数的值计算之后排序…

排序违规

一些人可能会考虑由于同时使用global_var并单独违反6.5 2而导致未定义的行为,该操作规定:

如果一个标量对象上的副作用在相对于同一标量对象的另一个副作用或使用同一标量对象的值计算时未被排序,其行为是未定义的...

对于许多C语言从业者来说,像 x + x++ 这样的表达式的行为是不定义的,因为它们都在同一个表达式中单独修改并使用 x 的值而没有排序。然而,在这种情况下,我们有一个函数调用,它提供了一些排序。 global_vararr [global_var] 中被使用,并且在函数调用 update_three(2) 中被更新。

6.5.2.2第10条告诉我们,在调用函数之前有一个顺序点:

在函数设计器和实际参数的评估之后,但在实际调用之前存在顺序点...

在函数内部,global_var = val; 是一个完整的表达式,return 3; 中的 3 也是如此,根据6.8第4条:

完整表达式是指不是另一个表达式的一部分,也不是声明符或抽象声明符的一部分的表达式...

然后,根据6.8第4条,在这两个表达式之间存在一个顺序点:

在评估完整表达式和下一个要评估的完整表达式之间存在一个序列点。

因此,C语言实现可能先评估arr [global_var],然后再进行函数调用,在这种情况下,在函数调用之前存在一个序列点,或者它可能在函数调用中评估global_var = val; 然后是arr [global_var],在这种情况下,在完整表达式之后存在一个序列点。因此,行为未指定 - 这两件事中的任何一件都可能首先被评估 - 但它不是未定义的。


26

这里的结果是未指定的。

虽然表达式中操作的顺序,即子表达式的分组方式是明确定义的,但评估的顺序并没有规定。在这种情况下,意味着可以先读取global_var,也可以先调用update_three,但无法知道哪个先发生。

这里不会有未定义的行为,因为函数调用引入了一个序列点,函数中的每个语句都包括修改global_var的语句都引入了序列点。

为了澄清,在C标准第3.4.3节中,定义了未定义行为

未定义行为

使用非便携或错误的程序构造或错误数据时,对此国际标准不强制执行要求的行为

第3.4.4节中定义了未指定行为

未指定行为

使用未指定值或其他行为,其中本国际标准提供两个或多个可能性,并且在任何情况下都没有进一步要求选择哪个

该标准规定函数参数的求值顺序是未指定的,这意味着在这种情况下,arr[0] 或者 arr[2] 会被设置为3。

“函数调用引入了一个序列点”是不充分的。如果左操作数先被评估,那就足够了,因为这样序列点会将左操作数与函数内部的评估分开。但是,如果左操作数在函数调用之后被评估,则由于调用函数而产生的序列点并不在函数内部的评估和左操作数的评估之间。您还需要将完整表达式分开的序列点。 - Eric Postpischil
2
@EricPostpischil 在 C11 之前的术语中,函数的进入和退出都有一个序列点。在 C11 的术语中,整个函数体相对于调用上下文是不确定顺序的。这两者都指定了同一件事,只是使用了不同的术语。 - M.M
这是绝对错误的。赋值语句参数的求值顺序是未指定的。至于这个特定赋值语句的结果,它创建了一个具有不可靠内容的数组,既不可移植,也本质上是错误的(与预期结果的语义不一致)。这是未定义行为的典型案例。 - kuroi neko
1
@kuroineko 只是因为输出可能会有所不同,并不自动使其成为未定义行为。标准对未定义行为和未指定行为有不同的定义,在这种情况下属于后者。 - dbush
@EricPostpischil 在这里你有序列点(来自C11信息性附录C):“在函数调用中函数设计者和实际参数的求值以及实际调用之间(6.5.2.2)”,“在完整表达式的求值和下一个要求值的完整表达式之间... /--/ ...返回语句中的(可选)表达式(6.8.6.4)”。还有,每个分号也是一个完整表达式。 - Lundin
显示剩余2条评论

1

1
行为并非未定义,而是未指定。自然而然地,应该避免依赖于任何一个。 - Antti Haapala -- Слава Україні
1
嗯,它不是无序的,而是不确定顺序的... 随机站在队列中的两个人是不确定顺序的。内部的Neo和Agent Smith是无序的,会发生未定义的行为。 - Antti Haapala -- Слава Україні

0

由于在赋值之前没有值可供分配,因此大多数C编译器会首先发出调用函数的代码并将结果保存在某个地方(寄存器、堆栈等),然后它们会发出将该值写入其最终目的地的代码,因此它们将在更改全局变量后读取它。让我们称之为“自然顺序”,这不是由任何标准定义的,而是由纯逻辑定义的。

然而,在优化过程中,编译器将尝试消除暂时存储值的中间步骤,并尝试尽可能直接地将函数结果写入最终目的地,在这种情况下,它们通常必须先读取索引,例如到寄存器,以便能够直接将函数结果移动到数组中。这可能会导致在更改全局变量之前读取它。

因此,这基本上是未定义的行为,具有非常糟糕的属性,即其结果很可能会因是否执行优化以及优化的程度而有所不同。作为开发人员,您的任务是通过编写以下代码来解决此问题:

int idx = global_var;
arr[idx] = update_three(2);

或者编码:

int temp = update_three(2);
arr[global_var] = temp;

作为一个好的经验法则:除非全局变量是const(或者它们不是,但你知道没有代码会改变它们作为副作用),否则你不应该直接在代码中使用它们,因为在多线程环境中,即使这样也可能是未定义的。
int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

由于编译器可能会读取两次并且另一个线程可以在两次读取之间更改值。然而,优化肯定会导致代码仅读取一次,因此现在您可能会得到不同的结果,这也取决于另一个线程的时间。因此,在使用之前,将全局变量存储到临时堆栈变量中,您将会少很多麻烦。请记住,如果编译器认为这是安全的,它很可能会优化掉它,并直接使用全局变量,因此最终它在性能或内存使用方面可能没有任何区别。

(以防万一有人问为什么会有人做x + 2 * x而不是3 * x - 在某些CPU上,加法非常快,乘以二的幂次方也很快,因为编译器会将它们转换为位移操作(2 * x == x << 1),但是乘以任意数字可能会非常慢,因此,与其乘以 3,通过将 x 左移一位并将 x 添加到结果中,您可以获得更快的代码-即使现代编译器也会执行甚至这个技巧,如果你乘以3并打开了侵略性优化,除非它是现代目标CPU,其中乘法与加法一样快,因为那时这个技巧会减慢计算。)


2
这不是未定义行为 - 标准列出了可能性,并在任何情况下选择其中之一。 - Antti Haapala -- Слава Україні
编译器不会将 3 * x 转换为两次读取 x。它可能会先读取一次 x,然后在读取的寄存器上执行 x + 2*x 方法。 - M.M
6
如果你只通过查看代码无法说出结果是什么,那么在C/C++中,“未定义行为”(undefined behavior)有一个非常特定的含义,而这并不是它的意思。其他回答者已经解释了为什么这个特定实例是“未指定”的,但不是“未定义”的。 - marcelm
3
我欣赏你想揭示电脑内部的一些细节,即使这超出了原问题的范围。然而,UB是非常精确的C/C++行话,应该小心使用,特别是当问题涉及到语言技术性时。您可以考虑使用正确的“未指定行为”术语,这将明显改善答案。 - kuroi neko
2
@Mecki,“Undefined在英语中有着非常特殊的含义”……但是在一个标记为“language-lawyer”的问题中,如果该语言对于“undefined”有其自己的“非常特殊的含义”,那么不使用该语言的定义只会导致混淆。 - TripeHound
显示剩余12条评论

-1

全局编辑:抱歉各位,我有点激动,写了很多废话。只是一个老家伙在发牢骚。

我曾经希望C语言能够幸免,但不幸的是,自从C11以来,它已经与C++平起平坐了。显然,要知道编译器在表达式中的副作用时会做什么,现在需要解决一个小数学谜题,其中涉及基于“位于同步点之前”的代码序列的部分排序。

我碰巧在K&R时代设计和实现了一些关键的实时嵌入式系统(包括电动汽车的控制器,如果引擎没有得到控制就可能撞向最近的墙壁,重达10吨的工业机器人,如果没有得到正确的命令就可能将人压成肉泥,以及一个系统层,虽然无害,但少于1%的系统开销就可以让几十个处理器耗尽它们的数据总线)。

我可能太老糊涂了,没能理解undefined和unspecified之间的区别,但是我认为我对并发执行和数据访问的含义还是有一个相当清楚的概念。在我这个有些见多识广的观点中,C++和现在的C程序员们对于他们钟爱的语言来处理同步问题的痴迷是个昂贵的白日梦。要么你知道什么是并发执行,并且不需要这些小玩意儿,要么你不知道,并且最好不要乱来,以免给整个世界带来麻烦。

所有这些令人眼花缭乱的内存屏障抽象都是由于多CPU缓存系统的一组临时限制而产生的,所有这些限制都可以安全地封装在常见的操作系统同步对象中,例如C++提供的互斥锁和条件变量。
与使用精细的特定CPU指令相比,在性能方面,这种封装的成本只是微不足道的。
volatile关键字(或者像我这样的系统程序员所关心的#pragma dont-mess-with-that-variable)已经足够告诉编译器停止重新排序内存访问。 通过直接使用asm指令来将低级驱动程序和操作系统代码与特定于CPU的指令混合,可以轻松生成最优代码。如果不熟悉底层硬件(缓存系统或总线接口)的工作原理,您无论如何都会编写无用、低效或有错误的代码。

稍微调整一下volatile关键字,Bob就可以成为除了最顽固的低级程序员之外的每个人的叔叔。 但是,C++数学怪人们却过分追求设计解决方案,他们倾向于为不存在的问题设计解决方案,并将编程语言的定义与编译器的规范混淆。

这一次改变需要破坏 C 的一个基本方面,因为这些“屏障”甚至必须在低级别的 C 代码中生成才能正常工作。这导致了表达式定义的混乱,而没有任何解释或理由。

总之,编译器可以从这个荒谬的 C 代码产生一致的机器码,只是 C++ 程序员处理晚期缓存系统不一致性的方式的遥远后果。
这给 C 的一个基本方面(表达式定义)造成了严重的混乱,以至于大多数 C 程序员 - 他们并不在意缓存系统,也是正确的 - 现在被迫依靠专家来解释 a = b() + c()a = b + c 之间的区别。

试图猜测这个不幸的数组将会变成什么是一种浪费时间和精力的行为。无论编译器会对其做出什么,这段代码都是病态的错误。唯一负责任的做法就是将其丢进垃圾桶。
从概念上讲,副作用总是可以从表达式中移出来,只需要在评估之前或之后显式地让修改发生在一个单独的语句中即可。
这种糟糕的代码在80年代可能还有合理的解释,当时你不能指望编译器优化任何东西。但现在编译器已经比大多数程序员更聪明了,所以剩下的只是一堆糟糕的代码。

我也不明白这个未定义/未指定的争论的重要性。你要么可以依赖编译器生成具有一致行为的代码,要么就不行。无论你称之为未定义还是未指定,似乎都是无关紧要的。

在我看来,C语言已经处于危险的K&R状态。一个有用的进化是增加常识安全措施。例如,利用这个先进的代码分析工具,规范强制编译器至少生成关于疯狂代码的警告,而不是悄悄地生成可能极不可靠的代码。
但是,这些家伙决定,例如,在C++17中定义了一个固定的评估顺序。现在,每个软件白痴都被积极地鼓励故意在他/她的代码中放置副作用,并沉浸在新编译器将以确定的方式处理混淆的保证中。
K&R是计算机世界的真正奇迹之一。花二十块钱就可以得到一份全面的语言规范(我见过单个人使用这本书编写完整的编译器),一份优秀的参考手册(目录通常会在几页内指向你问题的答案),以及一本教你如何合理使用语言的教科书。包括原理、例子和关于你可以滥用语言做非常愚蠢的事情的明智警告。

为了如此微不足道的收益而摧毁那些遗产,在我看来似乎是一种残忍的浪费。但是,我很可能完全没有看到重点。 也许有善良的人能够指引我一个利用这些副作用获得显著优势的新C代码示例?


如果在同一表达式中对同一对象产生副作用,则其行为未定义,C17 6.5/2。根据C17 6.5.18/3,这些是未排序的。但是,来自6.5/2的文本“如果标量对象上的副作用相对于同一标量对象的不同副作用或使用同一标量对象的值计算是未排序的,则其行为未定义。”不适用,因为函数内部的值计算在数组索引访问之前或之后被排序,无论赋值运算符本身是否具有未排序的操作数。 - Lundin
函数调用的作用有点像“互斥锁,防止无序访问”,如果你愿意的话。类似于诸如0,expr,0这样的晦涩逗号运算符。 - Lundin
我认为你相信标准的作者所说的:“未定义行为允许实现者不捕获某些难以诊断的程序错误。它还确定了可能符合语言扩展的领域:实现者可以通过提供官方未定义行为的定义来增强语言。”并且说标准并不意味着贬低那些不是严格符合规范的有用程序。我认为大多数标准的作者都会认为,寻求编写优质编译器的人们很明显... - supercat
应该将 UB 视为使编译器对客户更有用的机会。我怀疑有人能想象到编译器编写者会利用这个机会来回应“你的编译器处理这些代码比其他人都不如”的投诉,声称“那是因为标准不要求我们有用地处理它,而对程序行为不被标准规定的实现进行有用处理只会促进破损程序的编写”。 - supercat
我看不出你的评论有什么意义。依赖于编译器特定的行为是不可移植的保证,这也需要对编译器制造商具有极大的信心,因为他们随时可能取消任何这些“额外定义”。编译器唯一能做的就是生成警告,一个明智而有知识的程序员可能会决定像处理错误一样处理它们。我认为这个ISO标准的问题在于,它使得像OP示例这样糟糕的代码合法(与K&R表达式的定义相比,原因非常不清楚)。 - kuroi neko

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