数组索引(相对于表达式)在C中的求值顺序



查看此代码:

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的规范中是否有一部分指示了在这种特殊情况下操作的优先级?

左右操作数的顺序

要在arr[global_var] = update_three(2)中执行赋值,C实现必须计算操作数,并且作为副作用,更新左操作数的存储值。C 2018 6.5.16(关于赋值)第3段告诉我们左右操作数没有排序:

操作数的求值是无序列的。

这意味着C实现可以自由地首先计算左值arr[global_var](通过"计算左值",我们指的是弄清楚这个表达式指的是什么),然后计算update_three(2),最后将后者的值分配给前者;或者先评估update_three(2),然后计算左值,然后将前者分配给后者;或者以某种混合的方式评估左值和CCD_ 5,然后将右值分配给左左值。

在所有情况下,值到左值的赋值必须排在最后,因为6.5.16.3还说:

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

排序冲突

有些人可能会考虑未定义的行为,因为既使用global_var,又违反6.5 2单独更新,其中写道:

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

许多C从业者都很熟悉,像x + x++这样的表达式的行为不是由C标准定义的,因为它们都使用x的值,并在同一个表达式中单独修改它,而不进行排序。然而,在这种情况下,我们有一个函数调用,它提供了一些排序。CCD_ 9在CCD_。

6.5.2.2 10告诉我们在调用函数之前有一个序列点:

在函数指示符和实际参数的求值之后,但在实际调用之前有一个序列点…

在函数内部,global_var = val;是一个全表达式return 3;中的3也是,根据6.84:

完整表达式是一个不属于另一个表达式、也不属于声明符或抽象声明符的表达式…

然后这两个表达式之间有一个序列点,同样根据6.84:

…完整表达式的求值和下一个要求值的完整表达式的赋值之间有一个序列点。

因此,C实现可以首先评估arr[global_var],然后进行函数调用,在这种情况下,它们之间有一个序列点,因为在函数调用之前有一个,或者它可以评估函数调用中的global_var = val;,然后评估arr[global_var],在这种情况下,它们中间有一个顺序点,因为全表达式之后有一个。因此,行为是未指定的——这两件事中的任何一件都可以首先进行评估——但它并不是未定义的。

此处的结果是未指定

虽然表达式中的运算顺序(决定子表达式的分组方式)定义良好,但未指定求值的顺序。在这种情况下,这意味着要么先读取global_var,要么先调用update_three,但无法知道是哪一个。

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

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

未定义的行为

使用不可移植或错误的程序结构时的行为,或本国际标准对错误数据没有规定要求

,并在第3.4.4节中将未指定行为定义为:

未指定行为

使用未指定的值或其他行为国际标准提供了两种或多种可能性在任何情况下都没有选择的进一步要求

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

我尝试了一下,更新了条目0。

然而,根据这个问题:表达式的右手边总是会首先评估吗

评估顺序未指定且未排序。所以我认为应该避免这样的代码。

由于在有值要赋值之前发出赋值代码没有什么意义,大多数C编译器首先会发出调用函数并将结果保存在某个位置(寄存器、堆栈等)的代码,然后会发出将该值写入最终目标的代码,因此在全局变量更改后会读取全局变量。让我们称之为"自然秩序",不是由任何标准定义的,而是由纯粹的逻辑定义的。

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

因此,这基本上是未定义的行为,具有非常糟糕的特性,很可能结果会有所不同,这取决于是否进行了优化以及优化的积极性。作为开发人员,您的任务是通过以下两种方式解决该问题:

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

或编码:

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

作为一个很好的经验法则:除非全局变量是const(或者它们不是CCD_23,但你知道没有任何代码会将其作为副作用进行更改),否则你永远不应该在代码中直接使用它们,就像在多线程环境中一样,即使这可能是未定义的:

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移位1并将x添加到结果中,你可以获得更快的代码。即使是现代编译器也会执行这种技巧,如果你乘以3并启用积极的优化,除非它是一个现代目标CPU,在那里乘法和加法一样快,因为从那时起,这种技巧会减慢计算速度。)

全局编辑:对不起,伙计们,我太激动了,写了很多废话。只是一个老家伙在咆哮。

我本想相信C可以幸免,但遗憾的是,从C11开始,它就与C++不相上下了。显然,要知道编译器将如何处理表达式中的副作用,现在需要解决一个小数学难题,该难题涉及基于"位于的同步点之前"的代码序列的偏序。

我碰巧在K&R天(包括电动汽车的控制器,如果发动机没有得到控制,它可能会把人撞到最近的墙上,一个10吨重的工业机器人,如果没有得到正确的控制,它可以把人压成浆,还有一个系统层,虽然无害,但可以让几十个处理器用不到1%的系统开销把数据总线吸干)。

我可能太老或太笨了,无法区分未定义和未指定,但我认为我仍然很清楚并发执行和数据访问的含义。在我看来,C++和现在的C们痴迷于他们的宠物语言来解决同步问题是一个代价高昂的白日梦。要么你知道什么是并发执行,你不需要这些小发明,要么你不需要,你会帮全世界一个忙,不要试图破坏它。

所有这些令人垂涎的内存屏障抽象都是由于多CPU缓存系统的一组临时限制,所有这些都可以安全地封装在常见的操作系统同步对象中,例如C++提供的互斥和条件变量
在某些情况下,与使用细粒度特定CPU指令相比,这种封装的成本只是性能的微小下降
volatile关键字(或者#pragma dont-mess-with-that-variable表示所有我,作为一个系统程序员,小心)足以告诉编译器停止重新排序内存访问。使用直接的asm指令可以很容易地生成最佳代码,从而在低级驱动程序和操作系统代码中添加特定于CPU的特定指令。如果不深入了解底层硬件(缓存系统或总线接口)是如何工作的,那么无论如何,你一定会编写无用、低效或有故障的代码。

volatile关键字进行一分钟的调整,除了最强硬的低级别程序员的叔叔之外,Bob可能是所有人。相反,C++数学怪胎们花了一整天的时间来设计另一个令人费解的抽象,屈服于他们设计解决方案的典型倾向,寻找不存在的问题,并将编程语言的定义与编译器的规格混为一谈。

只是这一次,更改也需要破坏C的一个基本方面,因为即使在低级别的C代码中也必须生成这些"障碍"才能正常工作。除其他外,这对表达的定义造成了严重破坏,没有任何解释或理由。

总之,编译器可以从这段荒谬的C中生成一致的机器代码,这只是C++人员处理21世纪末缓存系统潜在不一致性的方式的一个遥远结果
它把C的一个基本方面(表达式定义)搞得一团糟,以至于绝大多数C程序员——他们根本不在乎缓存系统,这是正确的——现在被迫依靠大师来解释a = b() + c()a = b + c之间的区别。

无论如何,试图猜测这个不幸的阵列会变成什么样子都是时间和精力的净损失。不管编译器将如何处理它,这段代码都是病态的错误。唯一负责任的做法就是把它送到垃圾桶里
从概念上讲,副作用总是可以从表达式中去除,只需在单独的语句中显式地让修改发生在求值之前或之后
这种糟糕的代码在80年代可能是合理的,当时你不能指望编译器优化任何东西。但现在编译器已经变得比大多数程序员更聪明了,剩下的只是一段糟糕的代码。

我也不理解这场未定义/未指明的辩论的重要性。要么可以依靠编译器生成具有一致行为的代码,要么不能。无论你称之为未定义还是未指定,这似乎都是一个悬而未决的问题。

在我可以说知情的意见中,C在其K&R状态。一个有用的演变是增加常识性的安全措施。例如,使用这种高级代码分析工具,规范迫使编译器至少生成关于疯狂代码的警告,而不是默默地生成可能不可靠到极致的代码
但他们决定,例如,在C++17中定义一个固定的求值顺序。现在,每一个软件低能儿都被故意煽动在他/她的代码中添加副作用,因为新的编译器肯定会以确定性的方式热切地处理混淆。

K&R是计算机世界真正的奇迹之一。花20块钱,你就可以得到一本全面的语言说明书(我见过单个人只使用这本书编写完整的编译器)、一本优秀的参考手册(目录通常会在你的问题答案的几页内为你指明方向),以及一本教你以合理的方式使用语言的教科书。用合理的理由、例子和明智的警告语,提醒你可以滥用语言做非常非常愚蠢的事情。

对我来说,为了这么少的利益而破坏这一遗产似乎是一种残酷的浪费。但我很可能再次完全看不到这一点。也许某种灵魂可以为我指明一个新的C代码的方向,它充分利用了这些副作用?

最新更新