繁体   English   中英

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

[英]Order of evaluation of array indices (versus the expression) in 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) ,然后计算左值,然后将前者分配给后者; 或者以某种混合方式评估左值和update_three(2) ,然后将右值分配给左左值。

在所有情况下,将值分配给左值必须放在最后,因为 6.5.16 3 还说:

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

测序违规

由于同时使用global_var和单独更新它,有些人可能会考虑未定义的行为,这违反了 6.5 2,它说:

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

许多 C 语言从业者都非常熟悉x + x++等表达式的行为并没有在 C 标准中定义,因为它们都使用x的值并且在同一个表达式中单独修改它而没有排序。 但是,在这种情况下,我们有一个函数调用,它提供了一些排序。 global_vararr[global_var]使用,并在函数调用update_three(2)

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

在函数指示符和实际参数的计算之后但在实际调用之前有一个序列点......

在函数内部, global_var = val; 是一个完整的表达,因此是3return 3; , 每 6.8 4:

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

然后在这两个表达式之间有一个序列点,再次按照 6.8 4:

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

因此,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 (或者它们不是,但你知道没有代码会改变它们作为副作用),你不应该直接在代码中使用它们,就像在多线程环境中一样,即使这可以是未定义的:

int result = global_var + (2 * global_var);
// Is not guaranteed to be equal to `3 * global_var`!

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

(以防万一有人问为什么有人会做x + 2 * x而不是3 * x - 在某些 CPU 上,加法速度非常快,乘以 2 的幂也是如此,因为编译器会将这些转换为位移( 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++ 人员处理 2000 年代后期缓存系统潜在不一致的方式的一个遥远的结果。
它把 C 的一个基本方面(表达式定义)搞得一团糟,以至于绝大多数 C 程序员——他们不在乎缓存系统,这是正确的——现在被迫依赖大师来解释a = b() + c()a = b + c之间a = b() + c()区别。

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

我也无法理解这场未定义/未指定辩论的重要性。 您可以依靠编译器生成具有一致行为的代码,也可以不这样做。 您是否称其为未定义或未指定似乎是一个有争议的问题。

在我可以说是明智的观点中,C 在其 K&R 状态下已经足够危险了。 一个有用的演变是添加常识性安全措施。 例如,使用这种先进的代码分析工具,规范强制编译器实现至少生成关于疯子代码的警告,而不是默默地生成一个可能不可靠到极端的代码。
但是他们决定,例如,在 C++17 中定义一个固定的评估顺序。 现在,每个软件白痴都被积极地煽动故意在他/她的代码中加入副作用,相信新编译器会以一种确定性的方式急切地处理混淆。

K&R 是计算世界真正的奇迹之一。 花 20 美元,你就得到了该语言的全面规范(我见过一个人只使用这本书编写了完整的编译器),一本优秀的参考手册(目录通常会在你的答案的几页内指出你问题),以及教你如何以合理的方式使用这门语言的教科书。 完整的理由、例子和明智的警告,关于你可以滥用语言来做非常非常愚蠢的事情的多种方式。

以微薄的收益摧毁遗产对我来说似乎是一种残酷的浪费。 但同样,我很可能无法完全理解这一点。 也许某个好心人可以为我指出一个新的 C 代码示例的方向,该示例利用了这些副作用的显着优势?

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM