Java中的求值顺序规则是什么?

90

我在阅读一些Java文本,并获得了以下的代码:

int[] a = {4,4};
int b = 1;
a[b] = b = 0;

在文本中,作者没有给出明确的解释,而最后一行代码的作用是:a[1] = 0;

我不太确定我理解了什么:这个赋值语句是如何被执行的?


21
以下混乱的原因表明,你永远不应该这样做。因为很多人会被迫考虑它实际上是做什么,而不是显而易见的。请注意,这句话中的“this”指的是某个具体的行为或事情,需要上下文才能确定其含义。 - Martijn
任何类似这样的问题的正确答案是“不要那样做!”赋值应该被视为一个语句;将赋值用作返回值的表达式应该引发编译器错误,或者至少应该发出警告。不要那样做。 - Mason Wheeler
5个回答

181

让我非常清楚地说,因为人们经常误解这一点:

子表达式的求值顺序与结合性和优先级都是独立的。 结合性和优先级确定了操作符执行的顺序,但不确定 子表达式的求值顺序。您的问题是关于子表达式求值的顺序。

考虑A() + B() + C() * D()。 乘法比加法的优先级更高,加法是左结合的,因此这等效于(A() + B()) + (C() * D()) 但只知道这告诉您第一个加法会在第二个加法之前发生,并且乘法会在第二个加法之前发生。它不告诉您将调用A(),B(),C()和D()的顺序!(它也没有告诉您乘法是在第一个加法之前还是之后发生。)完全可以通过编译来遵守优先级和结合性的规则:

d = D()          // these four computations can happen in any order
b = B()
c = C()
a = A()
sum = a + b      // these two computations can happen in any order
product = c * d
result = sum + product // this has to happen last

所有的优先级和结合规则都被遵循了——第一个加法在第二个加法之前发生,乘法在第二个加法之前发生。显然我们可以以 任何 顺序调用 A()、B()、C() 和 D() 并仍然遵守优先级和结合性规则!

我们需要一条与优先级和结合性规则 无关 的规则来解释子表达式计算的顺序。在 Java (和 C#) 中,相关规则是“子表达式从左到右计算”。因为 A() 出现在 C() 左边,所以先计算 A(),不管 C() 参与乘法而 A() 只参与加法这一事实。

现在你有足够的信息回答你的问题了。在 a[b] = b = 0 中,结合性规则说这是 a[b] = (b = 0);,但这并不意味着 b=0 先运行!优先级规则说索引比赋值高,但这并不意味着索引器在最右边的赋值运行之前运行。

(更新:本回答的早期版本在下面的部分中有一些小而实际上不重要的遗漏,我已经进行了更正。我还写了一篇博客文章,介绍了为什么 Java 和 C# 中这些规则是合理的:https://ericlippert.com/2019/01/18/indexer-error-cases/

优先级和结合性只告诉我们在 b 被赋值之前,零被赋值给 b,因为零的赋值计算出在索引操作中被赋值的值。优先级和结合性本身并不说明 a[b] 是在 b=0 之前还是之后被计算。

同样的道理,A()[B()] = C()A()[B()] = C() 是一样的——我们只知道在赋值之前要进行索引。我们不知道首先运行的是 A()、B() 还是 C(),这是基于优先级和结合性问题,需要另一个规则来告诉我们。

这个规则是“当你有选择关于先做哪件事时,始终从左到右”。然而,在这种特定情况下有一个有趣的问题:抛出由空集合或超出范围的索引引起的副作用是否被认为是赋值左侧计算的一部分还是赋值本身的一部分?Java 选择了后者。(当然,只有代码已经错误时才会关注这个区别,因为正确的代码首先不会引用 null 或传递错误的索引。)

所以会发生什么呢?

  • a[b]b=0 的左侧,因此 a[b] 首先运行,结果是 a[1]。但是,检查此索引操作的“有效性”被延迟。
  • 然后执行 b=0
  • 接着执行验证,确保 a 是有效的,a[1] 在范围内。
  • 最后执行将值分配给 a[1]

因此,尽管在这个特定的情况下有一些微妙的问题需要考虑,但对于那些不应该出现在正确代码中的罕见错误情况,一般可以推断:左边的事情先发生。这就是你要寻找的规则。优先级和结合性的讨论既令人困惑又不相关。

即使是那些应该更清楚的人,也经常会弄错这些东西。我已经编辑过过多错误陈述规则的编程书籍了,所以很多人对优先级/结合性和计算顺序之间的关系有完全错误的信念——也就是说,实际上不存在这样的关系,它们是独立的。

如果您对此话题感兴趣,可以参考我的文章进行进一步阅读:

http://blogs.msdn.com/b/ericlippert/archive/tags/precedence/

虽然这些文章是关于C#的,但大多数内容同样适用于Java。


6
就我个人而言,我更倾向于使用一种思维模型,在第一步中,按照优先级和结合性构建一个表达式树。在第二步中,从根节点开始递归地评估该树。其中节点的评估方式为:先从左到右评估直接的子节点,然后再评估该节点本身。| 这种模型的优点之一是它可以轻松处理二元运算符具有副作用的情况。但主要优点在于它更符合我的思维方式。 - CodesInChaos
2
@Neil:C++对于求值顺序没有任何保证,从来都没有。(C语言也是如此。)Python严格按照优先级顺序保证它;与其他所有内容不同,赋值是从右到左的。 - Donal Fellows
2
@aroth,你听起来有些困惑。而运算优先级规则只是暗示子表达式需要在父表达式之前被计算,但并没有说明子表达式的计算顺序。Java和C#选择从左到右计算,而C和C++则选择未定义的行为。 - CodesInChaos
6
@noober:好的,考虑一下:M(A() + B(), C() * D(), E() + F())。你希望子表达式按照什么顺序进行计算?C()和D()应该在A()、B()、E()和F()之前计算,因为乘法比加法优先级更高吗?很容易说“显然”顺序应该不同。但是制定一个涵盖所有情况的实际规则却更加困难。C#和Java的设计者选择了一个简单易懂的规则:“从左到右”。你提出的替代方案是什么,为什么你认为你的规则更好? - Eric Lippert
@Eric,我希望你能过来这里[https://dev59.com/MHI95IYBdhLWcg3w1BhN#13353263]发表一些意见。 - Museful
显示剩余10条评论

33
Eric Lippert的杰出答案仍然不够有帮助,因为它讨论了一种不同的语言。这是Java,其中Java语言规范是语义的权威描述。特别是§15.26.1相关,因为它描述了=运算符的求值顺序(我们都知道它是右结合的,对吗?)。在此问题中关心的部分缩小到:

如果左操作数表达式是数组访问表达式(§15.13),则需要执行许多步骤:

  • 首先,将评估左操作数数组访问表达式的数组引用子表达式。如果此评估突然完成,则由于相同原因,赋值表达式也会突然完成;不会评估左操作数数组访问表达式的索引子表达式和右操作数,也不会进行赋值。
  • 否则,将评估左操作数数组访问表达式的索引子表达式。如果此评估突然完成,则由于相同原因,赋值表达式也会突然完成,并且不会评估右操作数,也不会进行赋值。
  • 否则,将评估右操作数。如果此评估突然完成,则由于相同原因,将突然完成赋值表达式,并且不会进行赋值。

[...然后继续描述分配本身的实际含义,为了简洁起见,我们可以忽略它...]

简而言之,Java有一个非常严格定义的求值顺序,在任何运算符或方法调用的参数中基本上都是从左到右。数组赋值是比较复杂的情况之一,但即使在那里也仍然是从左到右。 (JLS建议您不要编写需要这些复杂语义约束的代码,我也是这样认为:每个语句只有一个赋值就足够让您陷入麻烦!)
C和C++在这个领域与Java明显不同:它们的语言定义故意将求值顺序未定义,以实现更多的优化。C#似乎与Java类似,但我不太了解它的文献,无法指出其正式定义。(这真的因语言而异,Ruby严格按照L2R的顺序进行,Tcl也是如此,尽管由于不相关的原因缺少赋值运算符 per se ,而Python在L2R但在赋值方面是R2L,我觉得这很奇怪,但没办法。)

11
所以你的意思是Eric的回答是错误的,因为Java明确定义了它应该是他说的那样吗? - configurator
8
Java(和C#)中相关的规则是“子表达式从左到右进行评估”- 对我来说听起来他在谈论两者。 - configurator
2
有点困惑 - 这是否会使Eric Lippert的上面的答案不那么正确,还是只是引用一个特定的参考来证明它是正确的? - GreenieMeanie
6
@Greenie:Eric的答案是正确的,但正如我所说,你不能不小心地将一个语言领域中的见解应用于另一个领域。因此,我引用了权威的来源。 - Donal Fellows
1
有趣的是,在解析左侧变量之前,会先计算右侧表达式;在 a[-1]=c 中,会先计算 c,然后才会认为 -1 是无效的。 - ZhongYu
显示剩余4条评论

6
a[b] = b = 0;

1) 数组索引运算符比赋值运算符的优先级更高(参见this answer):

(a[b]) = b = 0;

2) 根据JLS的15.26章节,共有12个赋值运算符;所有这些运算符在语法上都是从右往左结合的。因此,a=b=c表示为a=(b=c),它将c的值赋给b,然后将b的值赋给a。

(a[b]) = (b=0);

3)根据JLS第15.7节的规定:

Java编程语言保证操作符的操作数按照特定的顺序进行评估,即从左到右。

并且

二元操作符的左操作数在右操作数的任何部分被评估之前完全被评估。

所以:

a) (a[b])首先被评估为a[1]

b) 然后(b=0)被评估为0

c) 最后评估(a[1] = 0)


1

你的代码等同于:

int[] a = {4,4};
int b = 1;
c = b;
b = 0;
a[c] = b;

这解释了结果。


7
为什么会这样的问题。 - Mat
@Mat 答案是因为考虑到问题中提供的代码,这就是底层发生的事情。这就是评估的方式。 - Jérôme Verstrynge
1
是的,我知道。但在我看来,这并没有回答问题,即为什么会发生这种评估。 - Mat
1
@Mat,“为什么会这样评估?”不是被问到的问题。“评估是如何发生的?”才是被问到的问题。 - Jérôme Verstrynge
1
@JVerstry:它们怎么不等价呢?左操作数的数组引用子表达式就是最左边的操作数。 因此说“首先执行最左边的操作数”与说“首先执行数组引用”完全相同。 如果Java规范的作者选择在解释这个特定规则时过于啰嗦和冗长,那很好; 这种事情很令人困惑,应该更加详细而不是更少。 但我不认为我的简洁描述在语义上与他们的啰里八嗦不同。 - Eric Lippert
显示剩余2条评论

0

考虑下面一个更深入的例子。

作为一个经验法则:

在解决这些问题时最好有一个运算符优先级规则和结合性的表格可供参考,例如http://introcs.cs.princeton.edu/java/11precedence/

以下是一个很好的例子:

System.out.println(3+100/10*2-13);

问题:上述行的输出是什么?
答案:应用优先级和结合律规则
步骤1:根据优先级规则:/ 和 * 运算符比 + - 运算符具有更高的优先级。因此,执行此方程式的起点将缩小为:
100/10*2

第二步:根据规则和优先级:/和*的优先级相等。
由于/和*运算符在优先级上相等,我们需要查看这些运算符之间的结合性。
根据这两个特定运算符的结合规则, 我们从左到右开始执行方程式,即首先执行100/10:
100/10*2
=100/10
=10*2
=20

第三步:方程式现在处于以下执行状态:

=3+20-13

根据规则和优先级:+ 和 - 的优先级相等。
现在我们需要看一下 + 和 - 运算符之间的结合性。根据这两个特定运算符的结合性,我们从左到右开始执行方程式,即首先执行 3+20:
=3+20
=23
=23-13
=10

编译后的正确输出为10。

再次强调,在解决这些问题时,拥有一张运算符优先级规则和结合性表格非常重要,例如http://introcs.cs.princeton.edu/java/11precedence/


1
你说“加号和减号运算符之间的结合性”是“从右到左”。试着使用这个逻辑来计算 10-4-3 - Pshemo
1
我怀疑这个错误可能是由于http://introcs.cs.princeton.edu/java/11precedence/页面顶部的`+`是一元运算符(具有从右到左的结合性),但加法`+`和减法`-`与乘法`*`、除法`/`和取模`%`一样,具有从左到右的结合性。 - Pshemo
发现问题并进行了修正,谢谢Pshemo。 - user1328876
这个答案解释了优先级和结合性,但正如Eric Lippert解释的那样,问题是关于求值顺序的,这是非常不同的。事实上,这并没有回答问题。 - Fabio says Reinstate Monica

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接