为什么x = x ++未定义?


19

它是未定义的,因为它x在序列点之间修改了两次。标准说它是未定义的,因此它是未定义的。
我知道的那么多。

但为什么?

我的理解是,禁止这样做可以使编译器更好地进行优化。当发明C时,这本来是有道理的,但现在看来似乎是一个很弱的论点。
如果我们今天要重新发明C,那么我们会这样做吗,还是可以做得更好?
或者,也许还有一个更深层次的问题,那就是很难为此类表达式定义一致的规则,因此最好禁止它们?

因此,假设我们今天要重新发明C。我想为诸如的表达式建议简单的规则x=x++,在我看来,这些规则比现有规则更有效。
与现有规则或其他建议相比,我希望您对建议的规则有意见。

建议规则:

  1. 在序列点之间,未指定评估顺序。
  2. 副作用立即发生。

没有涉及未定义的行为。表达式的计算结果等于或等于该值,但肯定不会格式化硬盘(奇怪的是,我从未见过x=x++格式化硬盘的实现)。

示例表达式

  1. x=x++-定义明确,不会改变x
    首先,x将其递增(立即x++求值时),然后将其旧值存储在中x

  2. x++ + ++x-递增x两次,计算为2*x+2
    尽管可以首先评估任一侧,但结果要么是x + (x+2)(首先是左侧),要么是(首先是(x+1) + (x+1)右侧)。

  3. x = x + (x=3)-未指定,x设置为x+36
    如果首先评估右边,则为x+3。也有可能x=3首先被评估,所以是3+3。在任何一种情况下,x=3赋值都会在x=3评估时立即发生,因此存储的值将被另一个赋值覆盖。

  4. x+=(x=3)-定义明确,设置x为6。
    您可以说这只是上述表达式的简写。
    但是我要说的是,+=必须在之后执行x=3,而不是分为两个部分(读取x,评估x=3,添加和存储新值)。

有什么优势?

一些评论提出了这一点。
我当然不认为x=x++在任何普通代码中都应使用诸如此类的表达式。
实际上,我要比这严格得多-我认为单独使用x++in 的唯一好用法x++;

但是,我认为语言规则必须尽可能简单。否则,程序员只是不了解它们。禁止在序列点之间两次更改变量的规则当然是大多数程序员都不了解的规则。

一个非常基本的规则是:
如果A有效,B有效,并且它们以有效方式组合,则结果有效。
x是有效的L值,x++是有效的表达式,并且=是将L值和表达式组合在一起的有效方法,那么为什么x=x++不合法呢?
C标准在这里是一个例外,该例外使规则复杂化。您可以搜索stackoverflow.com并查看此异常使人们感到困惑的程度。
所以我说-摆脱这种混乱。

===答案摘要===

  1. 为什么这样
    我试图在上一节中解释-我希望C规则要简单。

  2. 优化的潜力:
    这确实使编译器有了一些自由,但是我没有看到任何使我相信它可能很重要的东西。
    大多数优化仍然可以完成。例如,a=3;b=5;即使标准指定了顺序,也可以重新排序。诸如之类的表达式a=b[i++]仍然可以进行类似的优化。

  3. 您不能更改现有标准。
    我承认,我不能。我从没想过我可以真正改变标准和编译器。我只想考虑是否可以采取其他措施。


10
为什么这对您很重要?应该定义它,如果是,为什么?分配x给自己没有多大意义,如果您想增加x,可以说x++;-不需要分配。我要说它应该仅仅因为很难记住应该发生什么被定义。
Caleb 2012年

4
在我看来,这是一个很好的问题(“有些人按原样看待事物,然后问为什么,我梦见从未见过的事物,问为什么不这样做”)。(我认为)这纯粹是关于语言设计的问题,以C语法为例,而不是C语法的问题。我个人认为我们没有为诸如x ++ + ++ x或x = x ++之类的表达式定义行为的原因仅仅是因为它们有可能被误读。
杰米·泰勒

5
@ugoren:为什么需要预测结果。即使您确实写过这样的代码,在他们的头脑中也没有人会写这样的代码(多次提到),即使在第一次代码审查中,它也会被拒绝。因此,无需定义行为并为优化程序提供最佳机会来优化它。在每个示例中,您建议如果他们将代码添加到代码库中,那么我会射击。
马丁·约克

3
我会发现一个更有趣的问题是为什么写这个不是错误?当然,编译器可以检测到它的不确定行为,因此可能无法满足用户的实际需求,那么为什么没有出错呢?我知道很难发现一些行为不确定的情况,但这不是t
JohnB 2012年

3
禁止在序列点之间两次更改变量的规则当然是大多数程序员不理解的规则。 ”-您对此有任何证据吗?我看到的大多数问题都被问到了,因为程序员不知道该规则。有没有证据表明大多数人在解释之后仍然不理解
确保

Answers:


24

也许您应该首先回答为什么要定义它的问题?通过允许这样的表达式具有附加的副作用,在编程风格,可读性,可维护性或性能方面是否有任何优势?是

y = x++ + ++x;

比...更具可读性

y = 2*x + 2;
x += 2;

鉴于这种改变是极其根本的,并且会破坏现有的代码库。


1
我在问题中添加了“为什么”部分。我当然不建议使用这些表达式,但我对使用简单的规则来说明表达式的含义感兴趣。
ugoren 2012年

同样,此更改不会破坏现有代码,除非它调用了未定义的行为。如果我错了纠正我。
ugoren 2012年

3
好吧,这是一个更加哲学的答案:目前尚不确定。如果没有程序员使用它,那么您就不需要理解这样的表达式,因为不应有任何代码。如果您需要了解它们,那么显然那里必须有很多依赖于未定义行为的代码。;)
确保

1
根据定义,它不会破坏任何现有的代码库来定义行为。如果它们包含UB,就定义而言,它们已经损坏。
DeadMG 2012年

1
@ugoren:您的“为什么”部分仍然没有回答实际问题:您为什么要在代码中使用这个怪异的表达式?如果您无法给出令人信服的答案,那么整个讨论都没有定论。
Mike Baranczak 2012年

20

如今,使这种不确定的行为可以实现更好的优化的观点并不弱。实际上,它今天比C新时要强大得多。

当C是新的时,可以利用它进行更好优化的机器大多是理论模型。人们曾经讨论过构建CPU的可能性,其中编译器将指示CPU关于可以/应该与其他指令并行执行的指令。他们指出了一个事实,即允许它具有未定义的行为,意味着在这样的CPU上(如果确实存在),您可以安排指令的“增量”部分与其余的指令流并行执行。尽管他们对理论是正确的,但当时几乎没有什么硬件可以真正利用这种可能性。

这不再只是理论上的了。现在有硬件的生产和广泛使用(例如,安腾,VLIW DSP)的,可以真正利用这一点。它们确实确实允许编译器生成指令流,该指令流指定可以并行执行指令X,Y和Z。这不再是一个理论模型,而是真正用于实际工作的硬件。

国际海事组织,使这种定义的行为接近最坏的解决方案。您显然不应该使用这样的表达式。对于绝大多数代码,理想的行为是使编译器完全拒绝此类表达式。当时,C编译器没有进行必要的流程分析以可靠地检测到该内容。即使在最初的C标准出现时,它也不是很普遍。

我不确定今天的社区是否也可以接受-尽管许多编译器可以进行这种流分析,但它们通常仅在您请求优化时才进行。我怀疑大多数程序员是否想放慢“调试”构建的想法只是为了能够拒绝他们(理智)最初不会编写的代码。

C所做的是半合理的第二好的选择:告诉人们不要这样做,允许(但不要求)编译器拒绝代码。这样可以避免(进一步)避免从未使用过代码的人的编译速度,但仍允许某人编写愿意在需要时拒绝此类代码的编译器(和/或具有可以拒绝该代码的人们可以选择使用的代码)或他们认为合适的答案)。

至少,IMO,做出这种定义的行为将(至少接近)做出最糟糕的决定。在VLIW风格的硬件上,您的选择是生成缓慢的代码,以合理使用增量运算符,只是为了使滥用它们的糟糕代码变得无用,否则始终需要进行大量流程分析以证明您没有在处理糟糕的代码,因此只有在真正必要时才可以生成慢速(序列化)代码。

底线:如果要解决此问题,则应朝相反的方向思考。而不是定义此类代码的作用,您应该定义语言,以便根本就不允许这样的表达式(并且忍受大多数程序员可能会选择在执行该要求上更快地进行编译)。


IMO,没有理由相信,在大多数情况下,较慢的指令实际上比快速的指令要慢得多,并且这些指令始终会对程序性能产生影响。我会将此归类为过早的优化。
DeadMG

也许我缺少了一些东西-如果没人曾经写过这样的代码,那为什么还要关心它的优化呢?
ugoren 2012年

1
@ugoren:编写类似a=b[i++];(例如)的代码很好,并且对其进行优化是一件好事。但是,我看不出有损诸如此类的合理代码的++i++意义,就像具有定义的含义一样。
杰里·科芬

2
@ugoren问题是诊断之一。不完全禁止这样的表达式的唯一目的++i++就是恰恰是通常很难将它们与具有副作用的有效表达式(例如a=b[i++])区分开。对我们来说,这似乎很简单,但是如果我正确地记得《龙书》,那实际上是一个NP难题。这就是为什么这种行为是UB行为,而不是禁止行为。
康拉德·鲁道夫2012年

1
我不认为性能是有效的论据。考虑到两种情况之间的微小差异和非常快的执行速度,我很难相信这种情况是足够普遍的,以至于明显的性能下降-更不用说在许多处理器和架构上,定义它实际上是免费的。
DeadMG 2012年

9

C#编译器团队的首席设计师Eric Lippert在他的博客上发布了一篇文章,其中涉及选择在语言规范级别使功能未定义的一些注意事项。显然,C#是一种不同的语言,其语言设计涉及不同的因素,但是他提出的要点仍然是相关的。

他特别指出了拥有现有语言的编译器,既有实现又在委员会中有代表的问题。我不确定是否是这种情况,但通常与大多数C和C ++相关的规范讨论有关。

如您所说,还要注意的是编译器优化的性能潜力。尽管确实这几天的CPU性能比C刚开始时的性能高出许多数量级,但这些天完成的大量C编程是专门因为潜在的性能提升以及潜在的(假设的未来)而完成的。 )由于指令集处理副作用和顺序点的规则过于严格,因此CPU指令优化和多核处理优化无法实现。


从您链接到的文章来看,似乎C#与我的建议相距不远。副作用的顺序定义为“从引起副作用的线程中观察时”。我没有提到多线程,但是通常C不能保证另一个线程中的观察者有很多东西。
ugoren

5

首先,让我们看一下未定义行为的定义

3.4.3

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

2注释可能的未定义行为的范围包括从完全忽略具有不可预测结果的情况到在翻译或程序执行过程中的行为记录环境的特征(无论是否发出诊断消息),以终止转换或执行(伴随发出诊断消息)。

3示例未定义行为的一个示例是整数溢出时的行为

因此,换句话说,“未定义的行为”只是意味着编译器可以自由地以其希望的方式处理这种情况,并且任何此类操作都被视为“正确”。

所讨论问题的根源是以下条款:

6.5表达式

...
3运算符和操作数的分组由语法指示。 74) 除了作为SPECI音响以后ED(为函数调用()&&||?:,和逗号运营商),子表达式的求值的顺序,并且其中的副作用发生的顺序都是unspeci音响版

重点已添加。

给定一个像

x = a++ * --b / (c + ++d);

子表达式a++--bc,和++d可被评估以任何顺序。此外,的副作用a++--b以及++d可以在任何点处的下一个顺序点之前施加(IOW,即使a++之前被评估--b,它不能保证a将被更新 之前--b被评估)。就像其他人所说的那样,这种行为的基本原理是给予实现以最佳方式对操作进行重新排序的自由。

因此,像这样的表达式

x = x++
y = i++ * i++
a[i] = i++
*p++ = -*p    // this one bit me just yesterday

等等,将针对不同的实现方式(或针对具有不同优化设置或基于周围代码的相同实现方式)产生不同的结果

行为是不确定的,因此编译器没有义务“做正确的事”,无论可能是什么。上面的情况很容易捕获,但是有非常少量的情况很难在编译时捕获。

显然,您可以设计一种语言,以便严格定义评估顺序和应用副作用的顺序,而Java和C#都可以这样做,从而在很大程度上避免了C和C ++定义所导致的问题。

那么,为什么在3个标准修订版之后没有对C进行此更改?首先,这里有40年的遗留C代码,而且不能保证这样的更改不会破坏该代码。这给编译器编写者带来了一些负担,因为这样的更改将立即使所有现有的编译器不符合要求。每个人都必须进行重大重写。即使在快速,现代的CPU上,也可以通过调整评估顺序来实现真正的性能提升。


1
关于这个问题的很好的解释。我不同意破坏旧版应用程序-未定义/未指定行为的实现方式有时会在编译器版本之间更改,而标准没有任何更改。我不建议更改任何已定义的行为。
ugoren 2012年

4

首先,您必须了解未定义的不仅仅是x = x ++。没有人关心x = x ++,因为无论您将其定义为什么,都没有意义。未定义的内容更像是“ a = b ++,其中a和b恰好相同”-即

void f(int *a, int *b) {
    *a = (*b)++;
}
int i;
f(&i, &i);

根据对处理器体系结构(以及周围的语句,如果这是比示例更复杂的功能)最有效的方法,可以使用几种不同的方法来实现该功能。例如,两个明显的例子:

load r1 = *b
copy r2 = r1
increment r1
store *b = r1
store *a = r2

要么

load r1 = *b
store *a = r1
increment r1
store *b = r1

请注意,上面列出的第一个(使用更多指令和更多寄存器的代码)是在无法证明a和b都不同的所有情况下都需要使用的代码。


您确实显示了我的建议导致更多机器操作的情况,但对我而言似乎微不足道。而且编译器还有一定的自由度-我添加的唯一真正要求是b之前存储a
ugoren 2012年

3

遗产

关于今天可以重新发明C的假设无法成立。每天产生大量的C代码行,以至于在游戏进行过程中更改游戏规则是错误的。

当然,您可以根据自己的规则发明一种新的语言,例如C + =。但这不是C。


2
我真的认为我们今天不能重塑C。这并不意味着我们不能讨论这些问题。但是,我的建议并不是真正地重塑。转换未定义行为来定义或不特定可更新的标准时,可以做的,而语言仍然是C.
ugoren

2

声明已定义的内容不会更改现有的编译器以遵守您的定义。在很多地方可能显式或隐式依赖的假设的情况下,尤其如此。

假设的主要问题不在x = x++;(编译器可以很容易地检查它并发出警告),*p1 = (*p2)++而在等价时(p1[i] = p2[j]++;当p1和p2是函数的参数时),编译器不容易知道是否p1 == p2(在C99中)restrict为了增加在序列点之间假设p1!= p2的可能性,我们认为优化可能性很重要。


我看不出我的建议对p1[i]=p2[j]++。如果编译器不承担任何别名,就没有问题。如果不能,则必须按书进行- p2[j]先增加,再存储p1[i]。除了失去的优化机会似乎并不重要以外,我认为没有问题。
ugoren 2012年

第二段并非独立于第一段,而是一个假设可以渗入且难以追踪的地方的示例。
AProgrammer 2012年

第一段陈述的内容很明显-必须更改编译器以符合新标准。我真的认为我没有机会对此进行标准化并使编译器作者效仿。我只是认为值得讨论。
ugoren 2012年

问题不在于是否需要根据语言的任何变化来更改编译器,而是变化无处不在并且很难找到。最实际的做法很可能会改变中间格式上优化工程,即假装x = x++;一直没有写,但t = x; x++; x = t;还是x=x; x++;或任何你想要的语义(但有关诊断是什么?)。对于新语言,只需消除副作用即可。
AProgrammer 2012年

我对编译器结构不太了解。如果我真的想更改所有编译器,我会更在意。但是也许将其x++视为一个序列点,就好像它是一个函数调用inc_and_return_old(&x)一样可以解决问题。
ugoren 2012年

-1

在某些情况下,此类代码在新的C ++ 11标准中定义的。


5
关心详细吗?
ugoren 2012年

我认为x = ++x现在是定义明确的(但不是x = x++
MM
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.