看着这段代码:
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语言规范中是否有指定这种情况下操作的优先级的部分?
看着这段代码:
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语言实现可以先计算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_var
在 arr [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]
,在这种情况下,在完整表达式之后存在一个序列点。因此,行为未指定 - 这两件事中的任何一件都可能首先被评估 - 但它不是未定义的。
这里的结果是未指定的。
虽然表达式中操作的顺序,即子表达式的分组方式是明确定义的,但评估的顺序并没有规定。在这种情况下,意味着可以先读取global_var
,也可以先调用update_three
,但无法知道哪个先发生。
这里不会有未定义的行为,因为函数调用引入了一个序列点,函数中的每个语句都包括修改global_var
的语句都引入了序列点。
为了澄清,在C标准第3.4.3节中,定义了未定义行为:
未定义行为
使用非便携或错误的程序构造或错误数据时,对此国际标准不强制执行要求的行为
第3.4.4节中定义了未指定行为:
该标准规定函数参数的求值顺序是未指定的,这意味着在这种情况下,未指定行为
使用未指定值或其他行为,其中本国际标准提供两个或多个可能性,并且在任何情况下都没有进一步要求选择哪个
arr[0]
或者 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 * x == x << 1
),但是乘以任意数字可能会非常慢,因此,与其乘以 3,通过将 x 左移一位并将 x 添加到结果中,您可以获得更快的代码-即使现代编译器也会执行甚至这个技巧,如果你乘以3并打开了侵略性优化,除非它是现代目标CPU,其中乘法与加法一样快,因为那时这个技巧会减慢计算。)
3 * x
转换为两次读取 x。它可能会先读取一次 x,然后在读取的寄存器上执行 x + 2*x 方法。 - M.M全局编辑:抱歉各位,我有点激动,写了很多废话。只是一个老家伙在发牢骚。
我曾经希望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代码示例?
0,expr,0
这样的晦涩逗号运算符。 - Lundin
clang
报告一个功能请求,以便这段代码触发一个警告,在我看来。 - malat