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 规范中是否有部分指示在这种特定情况下操作的优先级?
我试过了,我更新了条目 0。
然而根据这个问题:will right hand side of an expression always evaluated first
评估顺序未指定且未排序。
所以我认为应该避免这样的代码。
左右操作数的顺序
要在 arr[global_var] = update_three(2)
中执行赋值,C 实现必须评估操作数,并且作为副作用,更新左操作数的存储值。 C 2018 6.5.16(关于赋值)第3段告诉我们左右操作数没有顺序:
The evaluations of the operands are unsequenced.
这意味着 C 实现可以自由地首先计算 左值 arr[global_var]
(通过“计算左值”,我们的意思是弄清楚这个表达式指的是什么) ,然后求值update_three(2)
,最后将后者的值赋值给前者;或者先计算 update_three(2)
,然后计算左值,然后将前者分配给后者;或以某种混合方式评估左值和 update_three(2)
,然后将右值分配给左值。
在所有情况下,值对左值的赋值必须在最后,因为6.5.16 3也说:
… The side effect of updating the stored value of the left operand is sequenced after the value computations of the left and right operands…
测序违规
有些人可能会考虑由于使用 global_var
和单独更新它而违反 6.5 2 的未定义行为,它说:
If a side effect on a scalar object is unsequenced relative to either a different side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined…
很多C从业者都比较熟悉,x + x++
等表达式的行为在C标准中是没有定义的,因为它们都使用了x
的值,并且分别在没有排序的相同表达。然而,在这种情况下,我们有一个函数调用,它提供了一些排序。 global_var
在 arr[global_var]
中使用并在函数调用 update_three(2)
中更新。
6.5.2.2 10 告诉我们调用函数之前有一个序列点:
There is a sequence point after the evaluations of the function designator and the actual arguments but before the actual call…
在函数内部,global_var = val;
是一个 完整表达式 ,return 3;
中的 3
也是如此,根据 6.8 4:
A full expression is an expression that is not part of another expression, nor part of a declarator or abstract declarator…
然后这两个表达式之间有一个序列点,同样根据 6.8 4:
… There is a sequence point between the evaluation of a full expression and the evaluation of the next full expression to be evaluated.
因此,C 实现可能会先评估 arr[global_var]
然后再进行函数调用,在这种情况下它们之间有一个序列点,因为在函数调用之前有一个序列点,或者它可能会评估 global_var = val;
在函数调用中,然后是 arr[global_var]
,在这种情况下,它们之间有一个序列点,因为在完整表达式之后有一个。所以行为是未指定的——这两件事中的任何一个都可能首先被评估——但它不是未定义的。
这里的结果是未指定。
虽然表达式中的运算顺序(指示子表达式的分组方式)已明确定义,但未指定 求值 的顺序。在这种情况下,这意味着可以先读取 global_var
或先调用 update_three
,但无法知道是哪一个。
这里有 not 未定义的行为,因为函数调用引入了序列点,函数中的每个语句也是如此,包括修改 global_var
的语句。
为澄清起见,C standard 将第 3.4.3 节中的未定义行为定义为:
undefined behavior
behavior, upon use of a nonportable or erroneous program construct or
of erroneous data,for which this International Standard imposes no
requirements
并将第 3.4.4 节中的未指定行为定义为:
unspecified behavior
use of an unspecified value, or other behavior where this
International Standard provides two or more possibilities and imposes
no further requirements on which is chosen in any instance
标准声明未指定函数参数的评估顺序,在这种情况下意味着 arr[0]
设置为 3 或 arr[2]
设置为 3。
因为在你有一个值要赋值之前发出赋值代码毫无意义,大多数 C 编译器将首先发出调用函数的代码并将结果保存在某处(寄存器、堆栈等),然后他们将发出将此值写入其最终目的地的代码,因此他们将在更改后读取全局变量。让我们称其为 "natural order",不是由任何标准定义的,而是由纯逻辑定义的。
然而在优化的过程中,编译器会尽量去掉将值临时存储在某处的中间步骤,并尽量将函数结果直接写到最终目的地,在这种情况下,他们往往会有首先阅读索引,例如到一个寄存器,以便能够直接将函数结果移动到数组中。这可能会导致在更改之前读取全局变量。
所以这基本上是未定义的行为,非常糟糕 属性 结果很可能会有所不同,具体取决于是否执行了优化以及此优化的积极程度。作为开发人员,您的任务是通过编码来解决该问题:
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++ 相提并论。显然,要知道编译器将如何处理表达式中的副作用,现在需要解决一个涉及基于 "is located before the synchronization point of".
的代码序列的部分排序的小数学谜题。
我碰巧在 K&R 时代设计并实现了一些关键的实时嵌入式系统(包括电动汽车的控制器,如果发动机不受控制,它可能会让人撞到最近的墙上,一个重达 10 吨的工业机器人,如果命令不当,它可能会把人压成肉泥,还有一个系统层,虽然无害,但会有几十个处理器以不到 1% 的系统开销吸干它们的数据总线)。
我可能太老太笨,无法区分未定义和未指定,但我认为我对并发执行和数据访问的含义还是很清楚的。在我可以说是知情的观点中,这种对 C++ 和现在的 C 人用他们的宠物语言接管同步问题的痴迷是一个代价高昂的白日梦。要么您知道什么是并发执行,并且您不需要任何这些小玩意儿,要么您不需要,这样您就可以帮助整个世界,而不是试图弄乱它。
所有这些令人眼花缭乱的内存屏障抽象只是由于多 CPU 缓存系统的一组临时限制,所有这些都可以安全地封装在公共 OS 中同步对象,例如 C++ 提供的互斥锁和条件变量。
在某些情况下,与使用细粒度特定 CPU 指令所能达到的效果相比,这种封装的成本只是性能的一分钟下降。
volatile
关键字(或者 #pragma dont-mess-with-that-variable
对于我,作为系统程序员,关心)已经足够告诉编译器停止重新排序内存访问。
使用直接的 asm 指令可以很容易地生成最佳代码,以散布低级驱动程序和 OS 带有临时 CPU 特定指令的代码。如果不深入了解底层硬件(高速缓存系统或总线接口)的工作原理,您肯定会编写无用、低效或错误的代码。
对 volatile
关键字进行一分钟的调整,Bob 将成为除了最顽固的低级程序员的叔叔之外的所有人。
取而代之的是,通常的 C++ 数学怪胎团伙在现场设计了另一个难以理解的抽象,屈从于他们设计解决方案的典型倾向,寻找不存在的问题,并将编程语言的定义与编译器的规范混淆。
只是这一次需要更改来破坏 C 的一个基本方面,因为即使在低级 C 代码中也必须生成这些 "barriers" 才能正常工作。除其他外,这对表达式的定义造成了严重破坏,没有任何解释或理由。
作为结论,编译器可以从这个荒谬的 C 代码中生成一致的机器代码这一事实只是 C++ 人员处理 2000 年代后期缓存系统潜在不一致的方式的遥远结果。
它把 C 的一个基本方面(表达式定义)搞得一团糟,以至于绝大多数 C 程序员——他们不在乎缓存系统,这是正确的——现在被迫依靠专家来解释a = b() + c()
和 a = b + c
之间的区别。
无论如何,试图猜测这个不幸的数组会变成什么样子是在浪费时间和精力。不管编译器将如何处理它,这段代码在病态上都是错误的。唯一负责的事情就是将它送到垃圾箱。
从概念上讲,副作用总是可以从表达式中移出,只需在单独的语句中显式地让修改发生在求值之前或之后。
这种糟糕的代码在 80 年代可能是合理的,那时你不能指望编译器优化任何东西。但是现在编译器早就比大多数程序员都聪明了,剩下的就是一堆烂代码。
我也无法理解这种未定义/未指定辩论的重要性。您要么可以依靠编译器生成具有一致行为的代码,要么不能。您是否称其为未定义或未指定似乎是一个有争议的问题。
据我所知,C 在其 K&R 状态下已经足够危险了。一个有用的演变是增加常识性安全措施。例如,利用这种高级代码分析工具,规范强制编译器实现至少生成有关疯狂代码的警告,而不是默默地生成可能极度不可靠的代码。
但是这些人决定,例如,在 C++17 中定义一个固定的评估顺序。现在,每个软件低能儿都被积极煽动故意在 his/her 代码中加入副作用,以确信新编译器将以一种确定性的方式急切地处理混淆。
K&R 是计算世界真正的奇迹之一。花 20 美元,你就可以获得该语言的综合规范(我见过单独的人仅使用这本书就编写了完整的编译器)、一本优秀的参考手册(table 的内容通常会在几页内指向你你的问题的答案),以及一本教你以明智的方式使用该语言的教科书。附上基本原理、示例和明智的警告语,说明您可以通过多种方式滥用语言来做非常非常愚蠢的事情。
为了如此微薄的利益而毁掉那片遗产对我来说似乎是一种残忍的浪费。但是我很可能还是完全看不到这一点。
也许有好心人可以指出一个新的 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 规范中是否有部分指示在这种特定情况下操作的优先级?
我试过了,我更新了条目 0。
然而根据这个问题:will right hand side of an expression always evaluated first
评估顺序未指定且未排序。 所以我认为应该避免这样的代码。
左右操作数的顺序
要在 arr[global_var] = update_three(2)
中执行赋值,C 实现必须评估操作数,并且作为副作用,更新左操作数的存储值。 C 2018 6.5.16(关于赋值)第3段告诉我们左右操作数没有顺序:
The evaluations of the operands are unsequenced.
这意味着 C 实现可以自由地首先计算 左值 arr[global_var]
(通过“计算左值”,我们的意思是弄清楚这个表达式指的是什么) ,然后求值update_three(2)
,最后将后者的值赋值给前者;或者先计算 update_three(2)
,然后计算左值,然后将前者分配给后者;或以某种混合方式评估左值和 update_three(2)
,然后将右值分配给左值。
在所有情况下,值对左值的赋值必须在最后,因为6.5.16 3也说:
… The side effect of updating the stored value of the left operand is sequenced after the value computations of the left and right operands…
测序违规
有些人可能会考虑由于使用 global_var
和单独更新它而违反 6.5 2 的未定义行为,它说:
If a side effect on a scalar object is unsequenced relative to either a different side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined…
很多C从业者都比较熟悉,x + x++
等表达式的行为在C标准中是没有定义的,因为它们都使用了x
的值,并且分别在没有排序的相同表达。然而,在这种情况下,我们有一个函数调用,它提供了一些排序。 global_var
在 arr[global_var]
中使用并在函数调用 update_three(2)
中更新。
6.5.2.2 10 告诉我们调用函数之前有一个序列点:
There is a sequence point after the evaluations of the function designator and the actual arguments but before the actual call…
在函数内部,global_var = val;
是一个 完整表达式 ,return 3;
中的 3
也是如此,根据 6.8 4:
A full expression is an expression that is not part of another expression, nor part of a declarator or abstract declarator…
然后这两个表达式之间有一个序列点,同样根据 6.8 4:
… There is a sequence point between the evaluation of a full expression and the evaluation of the next full expression to be evaluated.
因此,C 实现可能会先评估 arr[global_var]
然后再进行函数调用,在这种情况下它们之间有一个序列点,因为在函数调用之前有一个序列点,或者它可能会评估 global_var = val;
在函数调用中,然后是 arr[global_var]
,在这种情况下,它们之间有一个序列点,因为在完整表达式之后有一个。所以行为是未指定的——这两件事中的任何一个都可能首先被评估——但它不是未定义的。
这里的结果是未指定。
虽然表达式中的运算顺序(指示子表达式的分组方式)已明确定义,但未指定 求值 的顺序。在这种情况下,这意味着可以先读取 global_var
或先调用 update_three
,但无法知道是哪一个。
这里有 not 未定义的行为,因为函数调用引入了序列点,函数中的每个语句也是如此,包括修改 global_var
的语句。
为澄清起见,C standard 将第 3.4.3 节中的未定义行为定义为:
undefined behavior
behavior, upon use of a nonportable or erroneous program construct or of erroneous data,for which this International Standard imposes no requirements
并将第 3.4.4 节中的未指定行为定义为:
unspecified behavior
use of an unspecified value, or other behavior where this International Standard provides two or more possibilities and imposes no further requirements on which is chosen in any instance
标准声明未指定函数参数的评估顺序,在这种情况下意味着 arr[0]
设置为 3 或 arr[2]
设置为 3。
因为在你有一个值要赋值之前发出赋值代码毫无意义,大多数 C 编译器将首先发出调用函数的代码并将结果保存在某处(寄存器、堆栈等),然后他们将发出将此值写入其最终目的地的代码,因此他们将在更改后读取全局变量。让我们称其为 "natural order",不是由任何标准定义的,而是由纯逻辑定义的。
然而在优化的过程中,编译器会尽量去掉将值临时存储在某处的中间步骤,并尽量将函数结果直接写到最终目的地,在这种情况下,他们往往会有首先阅读索引,例如到一个寄存器,以便能够直接将函数结果移动到数组中。这可能会导致在更改之前读取全局变量。
所以这基本上是未定义的行为,非常糟糕 属性 结果很可能会有所不同,具体取决于是否执行了优化以及此优化的积极程度。作为开发人员,您的任务是通过编码来解决该问题:
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++ 相提并论。显然,要知道编译器将如何处理表达式中的副作用,现在需要解决一个涉及基于 "is located before the synchronization point of".
的代码序列的部分排序的小数学谜题。我碰巧在 K&R 时代设计并实现了一些关键的实时嵌入式系统(包括电动汽车的控制器,如果发动机不受控制,它可能会让人撞到最近的墙上,一个重达 10 吨的工业机器人,如果命令不当,它可能会把人压成肉泥,还有一个系统层,虽然无害,但会有几十个处理器以不到 1% 的系统开销吸干它们的数据总线)。
我可能太老太笨,无法区分未定义和未指定,但我认为我对并发执行和数据访问的含义还是很清楚的。在我可以说是知情的观点中,这种对 C++ 和现在的 C 人用他们的宠物语言接管同步问题的痴迷是一个代价高昂的白日梦。要么您知道什么是并发执行,并且您不需要任何这些小玩意儿,要么您不需要,这样您就可以帮助整个世界,而不是试图弄乱它。
所有这些令人眼花缭乱的内存屏障抽象只是由于多 CPU 缓存系统的一组临时限制,所有这些都可以安全地封装在公共 OS 中同步对象,例如 C++ 提供的互斥锁和条件变量。
在某些情况下,与使用细粒度特定 CPU 指令所能达到的效果相比,这种封装的成本只是性能的一分钟下降。
volatile
关键字(或者 #pragma dont-mess-with-that-variable
对于我,作为系统程序员,关心)已经足够告诉编译器停止重新排序内存访问。
使用直接的 asm 指令可以很容易地生成最佳代码,以散布低级驱动程序和 OS 带有临时 CPU 特定指令的代码。如果不深入了解底层硬件(高速缓存系统或总线接口)的工作原理,您肯定会编写无用、低效或错误的代码。
对 volatile
关键字进行一分钟的调整,Bob 将成为除了最顽固的低级程序员的叔叔之外的所有人。
取而代之的是,通常的 C++ 数学怪胎团伙在现场设计了另一个难以理解的抽象,屈从于他们设计解决方案的典型倾向,寻找不存在的问题,并将编程语言的定义与编译器的规范混淆。
只是这一次需要更改来破坏 C 的一个基本方面,因为即使在低级 C 代码中也必须生成这些 "barriers" 才能正常工作。除其他外,这对表达式的定义造成了严重破坏,没有任何解释或理由。
作为结论,编译器可以从这个荒谬的 C 代码中生成一致的机器代码这一事实只是 C++ 人员处理 2000 年代后期缓存系统潜在不一致的方式的遥远结果。
它把 C 的一个基本方面(表达式定义)搞得一团糟,以至于绝大多数 C 程序员——他们不在乎缓存系统,这是正确的——现在被迫依靠专家来解释a = b() + c()
和 a = b + c
之间的区别。
无论如何,试图猜测这个不幸的数组会变成什么样子是在浪费时间和精力。不管编译器将如何处理它,这段代码在病态上都是错误的。唯一负责的事情就是将它送到垃圾箱。
从概念上讲,副作用总是可以从表达式中移出,只需在单独的语句中显式地让修改发生在求值之前或之后。
这种糟糕的代码在 80 年代可能是合理的,那时你不能指望编译器优化任何东西。但是现在编译器早就比大多数程序员都聪明了,剩下的就是一堆烂代码。
我也无法理解这种未定义/未指定辩论的重要性。您要么可以依靠编译器生成具有一致行为的代码,要么不能。您是否称其为未定义或未指定似乎是一个有争议的问题。
据我所知,C 在其 K&R 状态下已经足够危险了。一个有用的演变是增加常识性安全措施。例如,利用这种高级代码分析工具,规范强制编译器实现至少生成有关疯狂代码的警告,而不是默默地生成可能极度不可靠的代码。
但是这些人决定,例如,在 C++17 中定义一个固定的评估顺序。现在,每个软件低能儿都被积极煽动故意在 his/her 代码中加入副作用,以确信新编译器将以一种确定性的方式急切地处理混淆。
K&R 是计算世界真正的奇迹之一。花 20 美元,你就可以获得该语言的综合规范(我见过单独的人仅使用这本书就编写了完整的编译器)、一本优秀的参考手册(table 的内容通常会在几页内指向你你的问题的答案),以及一本教你以明智的方式使用该语言的教科书。附上基本原理、示例和明智的警告语,说明您可以通过多种方式滥用语言来做非常非常愚蠢的事情。
为了如此微薄的利益而毁掉那片遗产对我来说似乎是一种残忍的浪费。但是我很可能还是完全看不到这一点。 也许有好心人可以指出一个新的 C 代码示例的方向,该代码充分利用了这些副作用?