第五章:文法 - 操作符优先级

优质
小牛编辑
134浏览
2023-12-01

操作符优先级

就像我们在第四章中讲解的,JavaScript版本的&&||很有趣,因为它们选择并返回它们的操作数之一,而不是仅仅得出truefalse的结果。如果只有两个操作数和一个操作符,这很容易推理。

  1. var a = 42;
  2. var b = "foo";
  3. a && b; // "foo"
  4. a || b; // 42

但是如果牵扯到两个操作符,和三个操作数呢?

  1. var a = 42;
  2. var b = "foo";
  3. var c = [1,2,3];
  4. a && b || c; // ???
  5. a || b && c; // ???

要明白这些表达式产生什么结果,我们就需要理解当在一个表达式中有多于一个操作符时,什么样的规则统治着操作符被处理的方式。

这些规则称为“操作符优先级”。

我打赌大多数读者都觉得自己已经很好地理解了操作符优先级。但是和我们在本系列丛书中讲解的其他一切东西一样,我们将拨弄这种理解来看看它到底有多扎实,并希望能在这个过程中学到一些新东西。

回想上面的例子:

  1. var a = 42, b;
  2. b = ( a++, a );
  3. a; // 43
  4. b; // 43

要是我们移除了( )会怎样?

  1. var a = 42, b;
  2. b = a++, a;
  3. a; // 43
  4. b; // 42

等一下!为什么这改变了赋给b的值?

因为,操作符要比=操作符的优先级低。所以,b = a++, a被翻译为(b = a++), a。因为(如我们前面讲解的)a++拥有 后副作用,赋值给b的值就是在++改变a之前的值42

这只是为了理解操作符优先级所需的一个简单事实。如果你将要把,作为一个语句序列操作符使用,那么知道它实际上拥有最低的优先级是很重要的。任何其他的操作符都将要比,结合得更紧密。

现在,回想上面的这个例子:

  1. if (str && (matches = str.match( /[aeiou]/g ))) {
  2. // ..
  3. }

我们说过赋值语句周围的( )是必须的,但为什么?因为&&拥有的优先级比=更高,所以如果没有( )来强制结合,这个表达式将被作为(str && matches) = str.match..对待。但是这将是个错误,因为(str && matches)的结果将不是一个变量(在这里是undefined),而是一个值,因此它不能成为=赋值的左边!

好了,那么你可能认为你已经搞定操作符优先级了。

让我们移动到更复杂的例子(在本章下面几节中我们将一直使用这个例子),来 真正 测试一下你的理解:

  1. var a = 42;
  2. var b = "foo";
  3. var c = false;
  4. var d = a && b || c ? c || b ? a : c && b : a;
  5. d; // ??

好的,邪恶,我承认。没有人会写这样的表达式串,对吧?也许 不会,但是我们将使用它来检视将多个操作符链接在一起时的各种问题,而链接多个操作符是一个非常常见的任务。

上面的结果是42。但是这根本没意思,除非我们自己能搞清楚这个答案,而不是将它插进JS程序来让JavaScript搞定它。

让我们深入挖掘一下。

第一个问题 —— 你可能还从来没问过 —— 是,第一个部分(a && b || c)是像(a && b) || c那样动作,还是像a && (b || c)那样动作?你能确定吗?你能说服你自己它们实际上是不同的吗?

  1. (false && true) || true; // true
  2. false && (true || true); // false

那么,这就是它们不同的证据。但是false && true || true到底是如何动作的?答案是:

  1. false && true || true; // true
  2. (false && true) || true; // true

那么我们有了答案。&&操作符首先被求值,而||操作符第二被求值。

但这不是因为从左到右的处理顺序吗?让我们把操作符的顺序倒过来:

  1. true || false && false; // true
  2. (true || false) && false; // false -- 不
  3. true || (false && false); // true -- 这才是胜利者!

现在我们证明了&&首先被求值,然后才是||,而且在这个例子中的顺序实际上是与一般希望的从左到右的顺序相反的。

那么什么导致了这种行为?操作符优先级。

每种语言都定义了自己的操作符优先级列表。虽然令人焦虑,但是JS开发者读过JS的列表却不太常见。

如果你熟知它,上面的例子一点儿都不会绊到你,因为你已经知道了&&要比||优先级高。但是我打赌有相当一部分读者不得不将它考虑一会。

注意: 不幸的是,JS语言规范没有将它的操作符优先级罗列在一个方便,单独的位置。你不得不通读并理解所有的文法规则。所以我们将试着以一种更方便的格式排列出更常见和更有用的部分。要得到完整的操作符优先级列表,参见MDN网站的“操作符优先级”(* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence)。

短接

在第四章中,我们在一个边注中提到了操作符&&||的“短接”性质。让我们更详细地重温它们。

对于&&||两个操作符来说,如果左手边的操作数足够确定操作的结果,那么右手边的操作数将 不会被求值。故而,有了“短接”(如果可能,它就会取捷径退出)这个名字。

例如,说a && b,如果a是falsyb就不会被求值,因为&&操作数的结果已经确定了,所以再去麻烦地检查b是没有意义的。同样的,说a || b,如果a是truthy,那么操作的结果就已经确定了,所以没有理由再去检查b

这种短接非常有帮助,而且经常被使用:

  1. function doSomething(opts) {
  2. if (opts && opts.cool) {
  3. // ..
  4. }
  5. }

opts && opts.cool测试的opts部分就像某种保护,因为如果opts没有被赋值(或不是一个object),那么表达式opts.cool就将抛出一个错误。opts测试失败加上短接意味着opts.cool根本不会被求值,因此没有错误!

相似地,你可以用||短接:

  1. function doSomething(opts) {
  2. if (opts.cache || primeCache()) {
  3. // ..
  4. }
  5. }

这里,我们首先检查opts.cache,如果它存在,我们就不会调用primeCache()函数,如此避免了潜在的不必要的工作。

更紧密的绑定

让我们把注意力转回前面全是链接的操作符的复杂语句的例子,特别是? :三元操作符的部分。? :操作对的优先级与&&||操作符比起来是高还是低?

  1. a && b || c ? c || b ? a : c && b : a

它是更像这样:

  1. a && b || (c ? c || (b ? a : c) && b : a)

还是这样?

  1. (a && b || c) ? (c || b) ? a : (c && b) : a

答案是第二个。但为什么?

因为&&优先级比||高,而||优先级比? :高。

所以,表达式(a && b || c)? :参与之前被 首先 求值。另一种常见的解释方式是,&&||要比? :“结合的更紧密”。如果倒过来成立的话,那么c ? c..将结合的更紧密,那么它就会如a && b || (c ? c..)那样动作(就像第一种选择)。

结合性

所以,&&||操作符首先集合,然后是? :操作符。但是多个同等优先级的操作符呢?它们总是从左到右或是从右到左地处理吗?

一般来说,操作符不是左结合的就是右结合的,这要看 分组是从左边发生还是从右边发生。

至关重要的是,结合性与从左到右或从右到左的处理 不是 同一个东西。

但为什么处理是从左到右或从右到左那么重要?因为表达式可以有副作用,例如函数调用:

  1. var a = foo() && bar();

这里,foo()首先被求值,然后根据表达式foo()的结果,bar()可能会求值。如果bar()foo()之前被调用绝对会得出不同的程序行为。

但是这个行为就是从左到右的处理(JavaScript中的默认行为!)—— 它与&&的结合性无关。在这个例子中,因为这里只有一个&&因此没有相关的分组,所以根本谈不上结合性。

但是像a && b && c这样的表达式,分组将会隐含地发生,意味着不是a && b就是b && c会先被求值。

技术上讲,a && b && c将会作为(a && b) && c处理,因为&&是左结合的(顺带一提,||也是)。然而,右结合的a && (b && c)也表现出相同的行为。对于相同的值,相同的表达式是按照相同的顺序求值的。

注意: 如果假设&&是右结合的,它就会与你手动使用( )建立a && (b && c)这样的分组的处理方式一样。但是这仍然 不意味着 c将会在b之前被处理。右结合性的意思 不是 从右到左求值,它的意思是从右到左 分组。不管哪种方式,无论分组/结合性怎样,严格的求值顺序将是a,然后b,然后c(也就是从左到右)。

因此,除了使我们对它们定义的讨论更准确以外,&&||是左结合这件事没有那么重要。

但事情不总是这样。一些操作符根据左结合性与右结合性将会做出不同的行为。

考虑? :(“三元”或“条件”)操作符:

  1. a ? b : c ? d : e;

? :是右结合的,那么哪种分组表现了它将被处理的方式?

  • a ? b : (c ? d : e)
  • (a ? b : c) ? d : e

答案是a ? b : (c ? d : e)。不像上面的&&||,在这里右结合性很重要,因为对于一些(不是全部!)值的组合来说(a ? b : c) ? d : e的行为将会不同。

一个这样的例子是:

  1. true ? false : true ? true : true; // false
  2. true ? false : (true ? true : true); // false
  3. (true ? false : true) ? true : true; // true

在其他的值的组合中潜伏着更加微妙的不同,即便他们的最终结果是相同的。考虑:

  1. true ? false : true ? true : false; // false
  2. true ? false : (true ? true : false); // false
  3. (true ? false : true) ? true : false; // false

在这个场景中,相同的最终结果暗示着分组是没有实际意义的。然而:

  1. var a = true, b = false, c = true, d = true, e = false;
  2. a ? b : (c ? d : e); // false, 仅仅对 `a` 和 `b` 求值
  3. (a ? b : c) ? d : e; // false, 对 `a`, `b` 和 `e` 求值

这样,我们就清楚地证明了? :是右结合的,而且在这个操作符与它自己链接的方式上,右结合性是发挥影响的。

另一个右结合(分组)的例子是=操作符。回想本章早先的链式赋值的例子:

  1. var a, b, c;
  2. a = b = c = 42;

我们早先断言过,a = b = c = 42的处理方式是,首先对c = 42赋值求值,然后是b = ..,最后是a = ..。为什么?因为右结合性,它实际上这样看待这个语句:a = (b = (c = 42))

记得本章前面,我们的复杂赋值表达式的实例吗?

  1. var a = 42;
  2. var b = "foo";
  3. var c = false;
  4. var d = a && b || c ? c || b ? a : c && b : a;
  5. d; // 42

随着我们使用优先级和结合性的知识把自己武装起来,我们应当可以像这样把这段代码分解为它的分组行为:

  1. ((a && b) || c) ? ((c || b) ? a : (c && b)) : a

或者,如果这样容易理解的话,可以用缩进表达:

  1. (
  2. (a && b)
  3. ||
  4. c
  5. )
  6. ?
  7. (
  8. (c || b)
  9. ?
  10. a
  11. :
  12. (c && b)
  13. )
  14. :
  15. a

让我们解析它:

  1. (a && b)"foo".
  2. "foo" || c"foo".
  3. 对于第一个?测试,"foo"是truthy。
  4. (c || b)"foo".
  5. 对于第二个?测试, "foo"是truthy。
  6. a42.

就是这样,我们搞定了!答案是42,正如我们早先看到的。其实它没那么难,不是吗?

消除歧义

现在你应该对操作符优先级(和结合性)有了更好的把握,并对理解多个链接的操作符如何动作感到更适应了。

但还存在一个重要的问题:我们应当一直编写完美地依赖于操作符优先级/结合性的代码吗?我们应该仅在有必要强制一种不同的处理顺序时使用( )手动分组吗?

或者,另一方面,我们应当这样认识吗:虽然这样的规则 实际上 是可以学懂的,但是太多的坑让我们不得不忽略自动优先级/结合性?如果是这样,我们应当总是使用( )手动分组并移除对这些自动行为的所有依赖吗?

这种争论是非常主观的,而且和第四章中关于 隐含 强制转换的争论是强烈对称的。大多数开发者对这两个争论的感觉是一样的:要么他们同时接受这两种行为并使用它们编码,要么他们同时摒弃两种行为并坚持手动/明确的写法。

当然,在这个问题上,我们不能给出比我在第四章中给出的更绝对的答案。但我向你展示了利弊,并且希望促进了你更深刻的理解,以使你可以做出合理而不是人云亦云的决定。

在我看来,这里有一个重要的中间立场。我们应当将操作符优先级/结合性 ( )手动分组两者混合进我们的程序 —— 我在第四章中对于 隐含的 强制转换的健康/安全用法做过同样的辩论,但当然不会没有界限地仅仅拥护它。

例如,对我来说if (a && b && c) ..是完全没问题的,而我不会为了明确表现结合性而写出if ((a && b) && c) ..,因为我认为这过于繁冗了。

另一方面,如果我需要链接两个? :条件操作符,我会理所当然地使用( )手动分组来使我意图的逻辑表达的绝对清晰。

因此,我在这里的意见和在第四章中的相似:在操作符优先级/结合性可以使代码更短更干净的地方使用操作符优先级/结合性,在( )手动分组可以帮你创建更清晰的代码并减少困惑的地方使用( )手动分组