C语言 让a=a++的语义不被定义的原因是什么?

3qpi33ja  于 2022-12-02  发布在  其他
关注(0)|答案(8)|浏览(187)

a = a++;

在C中是未定义的行为。我问的问题是:"为什么"
我的意思是,我知道要提供一个一致的顺序来完成事情可能很难。但是,某些编译器总是按照这样或那样的顺序来做(在给定的优化级别上)。那么,为什么这要由编译器来决定呢?
为了清楚起见,我想知道这是否是一个设计决定,如果是,是什么促使它?或者可能是某种硬件限制?

wkftcu5l

wkftcu5l1#

更新:这个问题是the subject of my blog on June 18th, 2012。谢谢你的好问题!
为什么?我想知道这是否是一个设计决定,如果是,是什么促使它?
你实际上是在要求ANSI C设计委员会的会议记录,而我手边没有这些记录。如果你的问题只能由那天在场的人来明确回答,那么你就必须找到那天在场的人。
不过,我可以回答一个更广泛的问题:
哪些因素导致语言设计委员会将法律的程序的行为(*)置为“未定义”或“实现已定义”(**)?
第一个主要因素是:**市场上是否存在两种在特定程序的行为上不一致的语言实现?如果FooCorp的编译器将M(A(), B())编译为“call A,call B,call M,”而BarCorp的编译器将其编译为“call B,call A,call M,”并且也不是“明显正确”的行为,那么语言设计委员会有强烈的动机说“你们都是对的,”并使其成为实施定义的行为。 特别是如果FooCorp和BarCorp都在委员会中有代表的情况下。
下一个主要因素是:**该特性是否自然地为实现提供了许多不同的可能性?**例如,在C#中,编译器对“查询解析”表达式的分析被指定为“将语法转换为不具有查询解析的等效程序,然后正常分析该程序”。实现几乎没有其他自由。
相比之下,C#规范规定,foreach循环应被视为try块内的等效while循环,但允许实现具有一定的灵活性。C#编译器可以这样说:例如“我知道如何在数组上更有效地实现foreach循环语义”,并使用数组“而不是像规范建议的那样将数组转换为序列。
第三个因素是:**该特性是否如此复杂,以至于详细分析其确切行为会很困难或很昂贵?**C#规范几乎没有提到如何实现匿名方法、lambda表达式、表达式树、动态调用、迭代器块和异步块;它仅仅描述了所需的语义和对行为的一些限制,而将其余部分留给实现。
第四个因素是:**该功能是否会给编译器带来很高的分析负担?**例如,在C#中,如果您有:

Func<int, int> f1 = (int x)=>x + 1;
Func<int, int> f2 = (int x)=>x + 1;
bool b = object.ReferenceEquals(f1, f2);

假设我们要求B为真。* 如何确定两个函数何时“相同”*?进行“内涵性”分析--函数体是否具有相同的内容?--是很困难的,而做一个“外延性”分析--当给定相同的输入时,函数是否有相同的结果?--就更难了。语言规范委员会应该尽量减少实现团队必须解决的开放研究问题的数量!
因此在C#中,这是由实现定义的;编译器可以根据其判断来选择使它们引用相等或不相等。
第五个因素是:该功能是否会对运行时环境造成很大负担?
例如,在C#中,数组末尾之后的解引用是定义良好的;它会产生一个数组索引超出边界的异常。这个特性在运行时可以以很小的代价实现--不是零,而是很小的代价。调用一个带有空接收器的示例或虚方法被定义为产生一个空被解除引用的异常;消除未定义行为的好处是为小的运行时间成本买单。
第六个因素是:定义行为是否会妨碍某些主要的优化?例如,C#定义了从引起副作用的线程观察到的副作用 * 的顺序。但是,从一个线程观察另一个线程的副作用的程序的行为是由实现定义的,除了少数“特殊”副作用。(比如volatile write,或者进入一个锁。)如果C#语言要求所有线程以相同的顺序观察相同的副作用,那么我们就不得不限制现代处理器有效地完成它们的工作;现代处理器依赖于无序执行和复杂的高速缓存策略来获得它们的高水平性能。
这些只是我想到的几个因素;当然,语言设计委员会在决定一个特性是“实现定义的”还是“未定义的”之前,还要考虑许多其他因素。
现在让我们回到你的具体例子。

C#语言 * 确实 * 严格定义了这种行为();增量的副作用在赋值的副作用之前发生。因此,这里不存在任何“嗯,这是不可能的”论点,因为选择一种行为并坚持下去是可能的。这也不排除优化的主要机会。而且,也不存在多种可能的复杂实现策略。
因此,我的 * 猜测 *,我强调这是一个 * 猜测 *,是C语言委员会将副作用的排序纳入实现定义的行为,因为市场上有多个编译器以不同的方式执行,没有一个明显“更正确”,而且委员会不愿意告诉他们中的一半人他们错了。
*)或者,有时候,它的编译器!但是让我们忽略这个因素。
**)“未定义”行为意味着代码可以做 * 任何事情 *,包括擦除您的硬盘。编译器不需要生成具有任何特定行为的代码,也不需要告诉您它正在生成具有未定义行为的代码。“实现定义”行为意味着编译器作者在选择实现策略时有相当大的自由度。但必须 * 选择策略 始终如一地使用策略 * 并 * 记录该选择 *。
)当然是从单个线程观察时。

t8e9dugd

t8e9dugd2#

它是未定义的,因为没有很好的理由去写这样的代码,并且通过不要求伪代码的任何特定行为,编译器可以更积极地优化写得很好的代码。例如,如果p碰巧指向i*p = i++可能会以一种导致崩溃的方式被优化。在*p被显式写出为i以得到i = i++的特定情况下,这也碰巧是未定义的,逻辑上遵循。

daolsyd0

daolsyd03#

这是不明确的,但在语法上没有错误。a应该是什么?=++都有相同的“时间”。因此,它没有定义任意的顺序,因为任何一个顺序都将与两个操作符定义中的一个冲突。

svmlkihl

svmlkihl4#

除少数例外情况外,表达式的求值顺序是未指定的;这是一个经过深思熟虑的设计决策,它允许实现重新安排所写内容的求值顺序,如果这样做会产生更高效的机器代码的话。类似地,++--的副作用的应用顺序是未指定的,超出了它发生在下一个序列点之前的要求,再次给予实现以最佳方式安排操作的自由度。
不幸的是,这意味着像a = a++这样的表达式的结果会因编译器、编译器设置、周围代码等的不同而不同。在语言标准中,这种行为被明确地称为undefined,这样编译器实现者就不必担心检测这种情况并针对它们发出诊断。

void foo(int *a, int *b)
{
  *a = (*b)++;
}

如果这是文件中唯一的函数(或者如果它的调用者在不同的文件中),那么在编译时就无法知道ab是否指向同一个对象;你是干什么的?
请注意,完全可以要求所有表达式按特定顺序求值,并且所有副作用都在求值的特定点应用; Java和C#就是这样做的,在这些语言中,像a = a++这样的表达式总是定义良好的。

laximzn5

laximzn55#

后缀++运算符返回递增前的值。(这就是++返回的结果)。在下一个点,不确定是先进行递增还是先进行赋值,因为这两个操作都应用于同一个对象(a),并且该语言没有说明这些运算符的求值顺序。

gpnt7bae

gpnt7bae6#

有人可能会提供另一个原因,但从优化(更好地说汇编程序表示)的Angular 来看,a需要被加载到CPU寄存器中,而后缀运算符的值应该被放置到另一个寄存器或相同的寄存器中。
因此,最后一次赋值取决于优化器使用一个寄存器还是两个寄存器。

cngwdvgl

cngwdvgl7#

在没有插入序列点的情况下更新同一对象两次是未定义的行为...

  • 因为这会让编译器编写者更高兴
  • 因为它允许实现以任何方式定义它
  • 因为它不会在不需要时强制特定约束
0h4hbjxa

0h4hbjxa8#

假设a是值为 0x0001FFFF 的指针,并且假设架构是分段的,使得编译器需要分别向高部分和低部分应用增量,在高部分和低部分之间具有进位。即增量之前的低部分和增量之后的高部分。
这个值是你所期望的值的两倍。它可能指向不属于应用程序的内存,或者它可能(通常)是一个陷阱表示。换句话说,一旦这个值被加载到寄存器中,CPU可能会引发硬件故障,使应用程序崩溃。即使它不会立即导致崩溃,它也是应用程序正在使用的一个严重错误的值。
同样的事情也会发生在其他基本类型上,C语言甚至允许int有陷阱表示。C语言试图在广泛的硬件上实现有效的代码。在像8086这样的分段机器上获得有效的代码是很困难的。通过使这种行为不明确,语言实现者有更多的自由来积极地优化。我不知道它是否在实践中产生了性能差异,但显然语言委员会希望把所有的好处都给优化者。

相关问题