看这段代码:
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规范中是否有部分指示这种特殊情况下的操作优先级?
clang
使这段代码触发警告恕我直言。
看这段代码:
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规范中是否有部分指示这种特殊情况下的操作优先级?
clang
使这段代码触发警告恕我直言。
Answers:
为了执行中的赋值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 的3
in 也是如此return 3;
:
甲充分表达是不是另一个表达式的一部分,也不是说明符或抽象声明符的一部分的表达...
然后在这两个表达式之间有一个序列点,同样是6.8 4:
…在一个完整表达式的评估与下一个要评估的完整表达式的评估之间存在一个序列点。
因此,C实现可以arr[global_var]
先求值然后进行函数调用,在这种情况下,由于在函数调用之前有一个序列点,因此它们之间存在一个序列点,或者它可以global_var = val;
在函数调用中求值,然后进行求值arr[global_var]
,在这种情况下它们之间有一个序列点,因为在完整表达式之后有一个。因此,行为是不确定的-可以首先评估这两件事中的任何一个-但它不是不确定的。
结果是 不确定的。
尽管明确定义了表示子表达式分组方式的表达式中的运算顺序,但未指定评估顺序。在这种情况下,这意味着global_var
先读取先调用update_three
,但是无法知道哪个。
有没有未定义的行为,因为函数调用会引入一个序列点,函数中的每个语句(包括Modifyed的语句)也是如此global_var
。
为了澄清,C标准定义了未定义的行为在3.4.3节中将为:
未定义的行为
使用非便携式或错误程序构造或错误数据时的行为,本国际标准对此不施加任何要求
并在第3.4.4节中将未指定的行为定义为:
未指明的行为
使用未指定的值,或本国际标准提供两种或两种以上可能性且在任何情况下均不对所选内容施加任何其他要求的其他行为
该标准指出未指定函数参数的求值顺序,在这种情况下,这意味着将其arr[0]
设置为3或arr[2]
将其设置为3。
由于在分配值之前先为赋值发出代码几乎没有意义,所以大多数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,乘法运算的速度与加法运算一样快,此后该技巧将减慢计算速度。)
3 * x
x读取两次。它可能会读取一次x,然后对其将x读入的寄存器执行x + 2 * x方法
language-lawyer
,所讨论的语言对于undefined具有其自身的 “非常特殊的含义”,您只会因不使用而引起混乱语言的定义。
全球编辑:对不起,我被炒鱿鱼了,而且写了很多废话。只是一个古老的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代码示例的方向?
0,expr,0
。