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
这闻起来有不确定的行为。当然,绝对不应对此进行故意编码。
摆弄

1
我同意这是不良编码的一个例子。
Jiminion

4
一些轶事结果:godbolt.org/z/hM2Jo2
Bob__

15
这与数组索引或操作顺序无关。它与C规范称为“序列点”的内容有关,尤其是赋值表达式不会在左手表达式和右手表达式之间创建序列点这一事实,因此编译器可以自由地这样做选择。
Lee Daniel Crocker

4
您应该向其报告功能请求,以clang使这段代码触发警告恕我直言。
malat

Answers:


51

左右操作数的顺序

为了执行中的赋值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语言从业者都非常熟悉C x + x++语言标准未定义的表达式行为,因为他们都使用的值x并在同一表达式中单独修改它而无需排序。但是,在这种情况下,我们有一个函数调用,它提供了一些排序。在函数调用中global_var使用arr[global_var]和更新update_three(2)

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

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

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

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

然后在这两个表达式之间有一个序列点,同样是6.8 4:

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

因此,C实现可以arr[global_var]先求值然后进行函数调用,在这种情况下,由于在函数调用之前有一个序列点,因此它们之间存在一个序列点,或者它可以global_var = val;在函数调用中求值,然后进行求值arr[global_var],在这种情况下它们之间有一个序列点,因为在完整表达式之后有一个。因此,行为是不确定的-可以首先评估这两件事中的任何一个-但它不是不确定的。


24

结果是 不确定的

尽管明确定义了表示子表达式分组方式的表达式中的运算顺序,但未指定评估顺序。在这种情况下,这意味着global_var先读取先调用update_three,但是无法知道哪个。

没有未定义的行为,因为函数调用会引入一个序列点,函数中的每个语句(包括Modifyed的语句)也是如此global_var

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

未定义的行为

使用非便携式或错误程序构造或错误数据时的行为,本国际标准对此不施加任何要求

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

未指明的行为

使用未指定的值,或本国际标准提供两种或两种以上可能性且在任何情况下均不对所选内容施加任何其他要求的其他行为

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


“函数调用引入序列点”是不够的。如果首先对左操作数求值,就足够了,因为随后序列点将左操作数与函数中的求值分开。但是,如果在调用函数后评估了左操作数,则由于调用函数而导致的顺序点不在函数的评估与左操作数的评估之间。您还需要分隔完整表达式的序列点。
埃里克·波斯特皮希尔

2
@EricPostpischil在C11之前的术语中,函数的入口和出口处都有一个序列点。在C11术语中,整个函数体根据调用上下文不确定地排序。这些都使用不同的术语来指定同一件事
MM

这是绝对错误的。未指定赋值参数的评估顺序。至于此特定分配的结果,就是创建了一个内容不可靠的数组,既不可移植又本质上是错误的(与语义或预期结果中的任何一个不一致)。不确定行为的完美案例。
kuroi neko

1
@kuroineko仅仅因为输出可以变化并不会自动使其变为未定义的行为。该标准对未定义行为和未指定行为有不同的定义,在这种情况下,后者就是后者。
dbush

@EricPostpischil您在这里有序列点(来自C11信息丰富的附件C):“在函数指定符的评估与函数调用与实际调用中的实际参数之间。(6.5.2.2)”,“在完整表达式的评估之间”以及下一个要求值的完整表达式... /-/ ...在return语句(6.8.6.4)中的(可选)表达式”。而且,在每个分号处也是如此,因为这是一个完整的表达。
伦丁

1

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

但是根据这个问题:表达式的右手总是总是先求值

评估顺序不确定。所以我认为应该避免这样的代码。


我也从条目0获得了更新。
Jiminion

1
该行为不是未定义但未指定。自然应避免依赖于任何一个。
Antti Haapala

我编辑过的@AnttiHaapala
Mickael B.

1
嗯,它不是无序的,而是不确定的顺序...随机排在队列中的2个人被不确定地排序。Smith代理中的Neo内部没有顺序,并且会发生不确定的行为。
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的乘积也是如此,因为编译器会将其转换为位移(2 * x == x << 1),但是与任意数字的乘法可能非常慢,而不是乘以3,而是通过将x乘以1并在结果上加上x来获得更快的代码-如果您乘以3并启用积极的优化,除非是现代目标,否则即使现代编译器也会执行该技巧CPU,乘法运算的速度与加法运算一样快,此后该技巧将减慢计算速度。)


2
它不是不确定的行为-标准名单的可能性,其中之一是选择在任何情况下
安蒂·哈帕拉

编译器不会将3 * xx读取两次。它可能会读取一次x,然后对其将x读入的寄存器执行x + 2 * x方法
MM

6
@Mecki “如果仅通过查看代码就无法说出结果是什么,那么结果是不确定的” – 在C / C ++中,不确定的行为具有非常特殊的含义,不是这样。其他答复者解释了为什么未指定未定义此特定实例的原因
marcelm

3
我很乐意为计算机内部注入一些光彩,即使这超出了原始问题的范围。但是,UB是非常精确的C / C ++术语,应谨慎使用,尤其是在有关语言技术性的问题时。您可能会考虑使用适当的“未指定行为”一词,这将大大改善答案。
kuroi neko

2
@Mecki“ Undefined在英语中具有非常特殊的含义 ” ...但是在标记为的问题中language-lawyer,所讨论的语言对于undefined具有其自身的 “非常特殊的含义”,您只会因不使用而引起混乱语言的定义。
TripeHound

-1

全球编辑:对不起,我被炒鱿鱼了,而且写了很多废话。只是一个古老的geezer咆哮。

我想相信C可以幸免,但是自从C11以来,它已经与C ++相提并论。显然,了解编译器将如何处理表达式中的副作用现在需要解决一些数学难题,其中涉及基于“位于同步点之前”的代码序列的部分排序。

我碰巧在K&R时代就设计并实现了一些关键的实时嵌入式系统(包括一辆电动汽车的控制器,如果不检查发动机,这可能会使人们撞到最近的墙壁上,这是一个10吨的工业用)如果没有正确的命令,它可以将人们挤到纸浆上;一个系统层,尽管无害,但可以使几十个处理器将其数据总线吸干,而系统开销不到1%。

我可能太老了或太笨了,无法理解未定义和未指定之间的区别,但是我认为我对并发执行和数据访问的含义仍然有很好的了解。用我可以说的见解,对C ++以及现在的C语言人员及其宠物语言接管同步问题的迷恋是一个昂贵的梦想。您要么知道什么是并发执行,要么您不需要任何这些小发明,要么您不需要,那么您将为整个世界带来一个忙,而不会试图使其混乱。

所有这些令人费解的内存屏障抽象工作都是由于多CPU缓存系统的一组临时限制,所有这些限制都可以安全地封装在常见的OS同步对象中,例如互斥锁和条件变量C ++提供。
在某些情况下,与使用细粒度的特定CPU指令相比,这种封装的成本只是性能下降了一分钟。
volatile关键字(或一#pragma dont-mess-with-that-variable对于作为系统程序员我所关心的所有事情,我已经足够告诉编译器停止对内存访问进行重新排序。可以使用直接的asm指令轻松生成最佳代码,以使用特定于CPU的特定指令散布低级驱动程序和OS代码。在不了解底层硬件(缓存系统或总线接口)如何工作的情况下,您肯定会编写无用,低效或错误的代码。

只要对volatile关键字和Bob进行细微的调整,除了最难熬的低级程序员的叔叔之外,其他所有人都是如此。取而代之的是,通常的C ++数学怪癖帮派们在野外设计另一个难以理解的抽象,这导致了他们设计解决方案的典型趋势,即寻找不存在的问题,并使编程语言的定义与编译器的规范相混淆。

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

结论是,编译器可以从这个荒唐的C语言中产生一致的机器代码这一事实仅仅是C ++专家应对2000年代后期高速缓存系统潜在的不一致的方式的遥远结果。
它弄糟了C(表达式定义)的一个基本方面,因此绝大多数C程序员(他们对缓存系统一无所知,是这样)现在被迫依靠专家来解释a = b() + c()和之间的区别a = b + c

试图猜测这个不幸的阵营将会变成什么,这是时间和精力的净损失。无论编译器将如何处理,此代码在病理上都是错误的。唯一负责的事情是将其发送到垃圾箱。
从概念上讲,总是可以在单独的语句中通过显式让修改在评估之前或之后进行的琐碎工作而从表达式中移出副作用。
当您不希望编译器进行任何优化时,这种低劣的代码可能已经在80年代得到了证明。但是,既然编译器已经比大多数程序员变得更加聪明,剩下的只是一段糟糕的代码。

我也无法理解这场不确定的/未指明的辩论的重要性。您可以依靠编译器来生成行为一致的代码,也可以不这样做。无论您称未定义还是未指定,都似乎是有争议的。

以我可以说的见解,C在K&R状态下已经足够危险。一个有用的发展是增加常识性安全措施。例如,使用此高级代码分析工具,规范会强制编译器实施至少生成关于bonkers代码的警告,而不是静默生成可能不可靠的代码。
但是,例如,他们决定在C ++ 17中定义固定的评估顺序。现在,人们积极地激励每一个软件功能不当的人故意在其代码中添加副作用,以确保新的编译器将以确定性的方式急于处理混淆问题。

K&R是计算机世界的真正奇迹之一。花二十美元,您就可以得到语言的全面说明(我见过一个人仅使用这本书就可以编写完整的编译器),一本出色的参考手册(目录通常会在几页内指出您的答案)问题)和一本教您如何明智地使用该语言的教科书。并附有理论,示例和明智的警告语,以警告您可以使用多种语言来做非常非常愚蠢的事情。

对遗产的破坏如此之少,对我而言似乎是残酷的浪费。但是我很可能完全看不到这一点。也许某种友善的心会让我指向利用这些副作用的新C代码示例的方向?


如果同一表达式中的同一对象C17 6.5 / 2有副作用,则这是未定义的行为。根据C17 6.5.18 / 3,这些序列未​​排序。但是,来自6.5 / 2的文本“如果相对于相同标量对象上的不同副作用或使用相同标量对象的值进行的值计算,相对于标量对象的副作用未排序,则该行为是不确定的。” 不适用,因为函数内部的值计算是在数组索引访问之前或之后进行排序的,而与赋值运算符本身具有未排序的操作数无关。
伦丁

如果可以的话,函数调用的行为类似于“互斥防止无序访问”。类似于晦涩的逗号运算符0,expr,0
伦丁

我认为您相信标准的作者,当他们说“未定义的行为使实施者获得许可,不会捕获某些难以诊断的程序错误时,还可以识别可能的语言扩展领域:实现者可以通过提供正式未定义行为的定义。” 并说该标准不应贬低那些不严格合规的有用程序。我认为该标准的大多数作者都会以为人们显然正在寻求编写高质量的编译器……
supercat

...应该寻求利用UB作为一个机会,使他们的编译器对他们的客户尽可能有用。我怀疑是否有人会想到编译器作者会以此为借口来回应“您的编译器处理该代码的效率低于其他所有人的抱怨”的抱怨,因为“这是因为标准不需要我们对它进行有用的处理,而实现有用地处理其行为不受标准强制要求的程序仅会促进破坏程序的编写”。
超级猫

我看不出你的意思。依赖于编译器特定的行为是不可移植性的保证。这也需要对编译器制造商有很高的信心,他们可以随时中断这些“额外定义”中的任何一个。编译器唯一能做的就是生成警告,明智和博学的程序员可能会决定警告,以处理类似的错误。我看到的这个ISO怪兽的问题是,它使OP的示例合法性如此残酷(出于极其不清楚的原因,与表达式的K&R定义相比)。
kuroi neko
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.