第五章:文法 - 语句与表达式

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

语句与表达式

一个很常见的现象是,开发者们假定“语句(statement)”和“表达式(expression)”是大致等价的。但是这里我们需要区分它们俩,因为在我们的JS程序中它们有一些非常重要的区别。

为了描述这种区别,让我们借用一下你可能更熟悉的术语:英语。

一个“句子(sentence)”是一个表达想法的词汇的完整构造。它由一个或多个“短语(phrase)”组成,它们每一个都可以用标点符号或连词(“和”,“或”等等)连接。一个短语本身可以由更小的短语组成。一些短语是不完整的,而且本身没有太多含义,而另一些短语可以自成一句。这些规则总体地称为英语的 文法

JavaScript文法也类似。语句就是句子,表达式就是短语,而操作符就是连词/标点。

JS中的每一个表达式都可以被求值而成为一个单独的,具体的结果值。举例来说:

  1. var a = 3 * 6;
  2. var b = a;
  3. b;

在这个代码段中,3 * 6是一个表达式(求值得值18)。而第二行的a也是一个表达式,第三行的b也一样。对表达式ab求值都会得到在那一时刻存储在这些变量中的值,也就偶然是18

另外,这三行的每一行都是一个包含表达式的语句。var a = 3 * 6var b = a称为“声明语句(declaration statments)”因为它们每一个都声明了一个变量(并选择性地给它赋值)。赋值a = 3 * 6b = a(除去var)被称为赋值表达式(assignment expressions)。

第三行仅仅含有一个表达式b,但是它本身也是一个语句(虽然不是非常有趣的一个!)。这一般称为一个“表达式语句(expression statement)”。

语句完成值

一个鲜为人知的事实是,所有语句都有完成值(即使这个值只是undefined)。

你要如何做才能看到一个语句的完成值呢?

最明显的答案是把语句敲进你的浏览器开发者控制台,因为当你运行它时,默认地控制台会报告最近一次执行的语句的完成值。

让我们考虑一下var b = a。这个语句的完成值是什么?

b = a赋值表达式给出的结果是被赋予的值(上面的18),但是var语句本身给出的结果是undefined。为什么?因为在语言规范中var语句就是这么定义的。如果你在你的控制台中敲入var a = 42,你会看到undefined被报告而不是42

注意: 技术上讲,事情要比这复杂一些。在ES5语言规范,12.2部分的“变量语句”中,VariableDeclaration算法实际上返回了一个值(一个包含被声明变量的名称的string —— 诡异吧!?),但是这个值基本上被VariableStatement算法吞掉了(除了在for..in循环中使用),而这强制产生一个空的(也就是undefined)完成值。

事实上,如果你曾在你的控制台上(或者一个JavaScript环境的REPL —— read/evaluate/print/loop工具)做过很多的代码实验的话,你可能看到过许多不同的语句都报告undefined,而且你也许从来没理解它是什么和为什么。简单地说,控制台仅仅报告语句的完成值。

但是控制台打印出的完成值并不是我们可以在程序中使用的东西。那么我们该如何捕获完成值呢?

这是个更加复杂的任务。在我们解释 如何 之前,让我们先探索一下 为什么 你想这样做。

我们需要考虑其他类型的语句的完成值。例如,任何普通的{ .. }块儿都有一个完成值,即它所包含的最后一个语句/表达式的完成值。

考虑如下代码:

  1. var b;
  2. if (true) {
  3. b = 4 + 38;
  4. }

如果你将这段代码敲入你的控制台/REPL,你可能会看到它报告42,因为42if块儿的完成值,它取自if的最后一个复制表达式语句b = 4 + 38

换句话说,一个块儿的完成值就像 隐含地返回 块儿中最后一个语句的值。

注意: 这在概念上与CoffeeScript这样的语言很类似,它们隐含地从functionreturn值,这些值与函数中最后一个语句的值是相同的。

但这里有一个明显的问题。这样的代码是不工作的:

  1. var a, b;
  2. a = if (true) {
  3. b = 4 + 38;
  4. };

我们不能以任何简单的语法/文法来捕获一个语句的完成值并将它赋值给另一个变量(至少是还不能!)。

那么,我们能做什么?

警告: 仅用于演示的目的 —— 不要实际地在你的真实代码中做如下内容!

我们可以使用臭名昭著的eval(..)(有时读成“evil”)函数来捕获这个完成值。

  1. var a, b;
  2. a = eval( "if (true) { b = 4 + 38; }" );
  3. a; // 42

啊呀呀。这太难看了。但是这好用!而且它展示了语句的完成值是一个真实的东西,不仅仅是在控制台中,还可以在我们的程序中被捕获。

有一个称为“do表达式”的ES7提案。这是它可能工作的方式:

  1. var a, b;
  2. a = do {
  3. if (true) {
  4. b = 4 + 38;
  5. }
  6. };
  7. a; // 42

do { .. }表达式执行一个块儿(其中有一个或多个语句),这个块儿中的最后一个语句的完成值将成为do表达式的完成值,它可以像展示的那样被赋值给a

这里的大意是能够将语句作为表达式对待 —— 他们可以出现在其他语句内部 —— 而不必将它们包装在一个内联的函数表达式中,并实施一个明确的return ..

到目前为止,语句的完成值不过是一些琐碎的事情。不过随着JS的进化它们的重要性可能会进一步提高,而且很有希望的是do { .. }表达式将会降低使用eval(..)这样的东西的冲动。

警告: 重复我刚才的训诫:避开eval(..)。真的。更多解释参见本系列的 作用域与闭包 一书。

表达式副作用

大多数表达式没有副作用。例如:

  1. var a = 2;
  2. var b = a + 3;

表达式a + 3本身并没有副作用,例如改变a。它有一个结果,就是5,而且这个结果在语句b = a + 3中被赋值给b

一个最常见的(可能)带有副作用的表达式的例子是函数调用表达式:

  1. function foo() {
  2. a = a + 1;
  3. }
  4. var a = 1;
  5. foo(); // 结果:`undefined`,副作用:改变 `a`

还有其他的副作用表达式。例如:

  1. var a = 42;
  2. var b = a++;

表达式a++有两个分离的行为。首先,它返回a的当前值,也就是42(然后它被赋值给b)。但 接下来,它改变a本身的值,将它增加1。

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

许多开发者错误的认为ba一样拥有值43。这种困惑源自没有完全考虑++操作符的副作用在 什么时候 发生。

++递增操作符和--递减操作符都是一元操作符(见第四章),它们既可以用于后缀(“后面”)位置也可用于前缀(“前面”)位置。

  1. var a = 42;
  2. a++; // 42
  3. a; // 43
  4. ++a; // 44
  5. a; // 44

++++a这样用于前缀位置时,它的副作用(递增a)发生在值从表达式中返回 之前,而不是a++那样发生在 之后

注意: 你认为++a++是一个合法的语法吗?如果你试一下,你将会得到一个ReferenceError错误,但为什么?因为有副作用的操作符 要求一个变量引用 来作为它们副作用的目标。对于++a++来说,a++这部分会首先被求值(因为操作符优先级 —— 参见下面的讨论),它会给出a在递增 之前 的值。但然后它试着对++42求值,这将(如果你试一下)会给出相同的ReferenceError错误,因为++不能直接在42这样的值上施加副作用。

有时它会被错误地认为,你可以通过将a++包进一个( )中来封装它的 副作用,比如:

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

不幸的是,( )本身不会像我们希望的那样,定义一个新的被包装的表达式,而它会在a++表达式的 后副作用 求值。事实上,就算它能,a++也会首先返回42,而且除非你有另一个表达式在++的副作用之后对a再次求值,你也不会从这个表达式中得到43,于是b不会被赋值为43

虽然,有另一种选择:,语句序列逗号操作符。这个操作符允许你将多个独立的表达式语句连成一个单独的语句:

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

注意: a++, a周围的( .. )是必需的。其原因的操作符优先级,我们将在本章后面讨论。

表达式a++, a意味着第二个a语句表达式会在第一个a++语句表达式的 后副作用 进行求值,这表明它为b的赋值返回43

另一个副作用操作符的例子是delete。正如我们在第二章中展示的,delete用于从一个object或一个array值槽中移除一个属性。但它经常作为一个独立语句被调用:

  1. var obj = {
  2. a: 42
  3. };
  4. obj.a; // 42
  5. delete obj.a; // true
  6. obj.a; // undefined

如果被请求的操作是合法/可允许的,delete操作符的结果值为true,否则结果为false。但是这个操作符的副作用是它移除了属性(或数组值槽)。

注意: 我们说合法/可允许是什么意思?不存在的属性,或存在且可配置的属性(见本系列 this与对象原型 的第三章)将会从delete操作符中返回true。否则,其结果将是false或者一个错误。

副作用操作符的最后一个例子,可能既是明显的也是不明显的,是=赋值操作符。

考虑如下代码:

  1. var a;
  2. a = 42; // 42
  3. a; // 42

对于这个表达式来说,a = 42中的=看起来似乎不是一个副作用操作符。但如果我们检视语句a = 42的结果值,会发现它就是刚刚被赋予的值(42),所以向a赋予的相同的值实质上是一种副作用。

提示: 相同的原因也适用于+=-=这样的复合赋值操作符的副作用。例如,a = b += 2被处理为首先进行b += 2(也就是b = b + 2),然后这个赋值的结果被赋予a

这种赋值表达式(语句)得出被赋予的值的行为,主要在链式赋值上十分有用,就像这样:

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

这里,c = 42被求值得出42(带有将42赋值给c的副作用),然后b = 42被求值得出42(带有将42赋值给b的副作用),而最后a = 42被求值(带有将42赋值给a的副作用)。

警告: 一个开发者们常犯的错误是将链式赋值写成var a = b = 42这样。虽然这看起来是相同的东西,但它不是。如果这个语句发生在没有另外分离的var b(在作用域的某处)来正式声明它的情况下,那么var a = b = 42将不会直接声明b。根据strict模式的状态,它要么抛出一个错误,要么无意中创建一个全局变量(参见本系列的 作用域与闭包)。

另一个要考虑的场景是:

  1. function vowels(str) {
  2. var matches;
  3. if (str) {
  4. // 找出所有的元音字母
  5. matches = str.match( /[aeiou]/g );
  6. if (matches) {
  7. return matches;
  8. }
  9. }
  10. }
  11. vowels( "Hello World" ); // ["e","o","o"]

这可以工作,而且许多开发者喜欢这么做。但是使用一个我们可以利用赋值副作用的惯用法,可以通过将两个if语句组合为一个来进行简化:

  1. function vowels(str) {
  2. var matches;
  3. // 找出所有的元音字母
  4. if (str && (matches = str.match( /[aeiou]/g ))) {
  5. return matches;
  6. }
  7. }
  8. vowels( "Hello World" ); // ["e","o","o"]

注意: matches = str.match..周围的( .. )是必需的。其原因是操作符优先级,我们将在本章稍后的“操作符优先级”一节中讨论。

我偏好这种短一些的风格,因为我认为它明白地表示了两个条件其实是有关联的,而非分离的。但是与大多数JS中的风格选择一样,哪一种 更好 纯粹是个人意见。

上下文规则

在JavaScript文法规则中有好几个地方,同样的语法根据它们被使用的地方/方式不同意味着不同的东西。这样的东西可能,孤立的看,导致相当多的困惑。

我们不会在这里详尽地罗列所有这些情况,而只是指出常见的几个。

{ .. } 大括号

在你的代码中一对{ .. }大括号将主要出现在两种地方(随着JS的进化会有更多!)。让我们来看看它们每一种。

对象字面量

首先,作为一个object字面量:

  1. // 假定有一个函数`bar()`的定义
  2. var a = {
  3. foo: bar()
  4. };

我们怎么知道这是一个object字面量?因为{ .. }是一个被赋予给a的值。

注意: a这个引用被称为一个“l-值”(也称为左手边的值)因为它是赋值的目标。{ .. }是一个“r-值”(也称为右手边的值)因为它仅被作为一个值使用(在这里作为赋值的源)。

标签

如果我们移除上面代码的var a =部分会发生什么?

  1. // 假定有一个函数`bar()`的定义
  2. {
  3. foo: bar()
  4. }

许多开发者臆测{ .. }只是一个独立的没有被赋值给任何地方的object字面量。但事实上完全不同。

这里,{ .. }只是一个普通的代码块儿。在JavaScript中拥有一个这样的独立{ .. }块儿并不是一个很惯用的形式(在其他语言中要常见得多!),但它是完美合法的JS文法。当与let块儿作用域声明组合使用时非常有用(见本系列的 作用域与闭包)。

这里的{ .. }代码块儿在功能上差不多与附着在一些语句后面的代码块儿是相同的,比如for/while循环,if条件,等等。

但如果它是一个一般代码块儿,那么那个看起来异乎寻常的foo: bar()语法是什么?它怎么会是合法的呢?

这是因为一个鲜为人知的(而且,坦白地说,不鼓励使用的)称为“打标签的语句”的JavaScript特性。foo是语句bar()(这个语句省略了末尾的;—— 见本章稍后的“自动分号”)的标签。但一个打了标签的语句有何意义?

如果JavaScript有一个goto语句,那么在理论上你就可以说goto foo并使程序的执行跳转到代码中的那个位置。goto通常被认为是一种糟糕的编码惯用形式,因为它们使代码更难于理解(也称为“面条代码”),所以JavaScript没有一般的goto语句是一件 非常好的事情

然而,JS的确支持一种有限的,特殊形式的goto:标签跳转。continuebreak语句都可以选择性地接受一个指定的标签,在这种情况下程序流会有些像goto一样“跳转”。考虑一下代码:

  1. // 用`foo`标记的循环
  2. foo: for (var i=0; i<4; i++) {
  3. for (var j=0; j<4; j++) {
  4. // 每当循环相遇,就继续外层循环
  5. if (j == i) {
  6. // 跳到被`foo`标记的循环的下一次迭代
  7. continue foo;
  8. }
  9. // 跳过奇数的乘积
  10. if ((j * i) % 2 == 1) {
  11. // 内层循环的普通(没有被标记的) `continue`
  12. continue;
  13. }
  14. console.log( i, j );
  15. }
  16. }
  17. // 1 0
  18. // 2 0
  19. // 2 1
  20. // 3 0
  21. // 3 2

注意: continue foo不意味着“走到标记为‘foo’的位置并继续”,而是,“继续标记为‘foo’的循环,并进行下一次迭代”。所以,它不是一个 真正的 随意的goto

如你所见,我们跳过了乘积为奇数的3 1迭代,而且被打了标签的循环跳转还跳过了1 12 2的迭代。

也许标签跳转的一个稍稍更有用的形式是,使用break __从一个内部循环里面跳出外部循环。没有带标签的break,同样的逻辑有时写起来非常尴尬:

  1. // 用`foo`标记的循环
  2. foo: for (var i=0; i<4; i++) {
  3. for (var j=0; j<4; j++) {
  4. if ((i * j) >= 3) {
  5. console.log( "stopping!", i, j );
  6. // 跳出被`foo`标记的循环
  7. break foo;
  8. }
  9. console.log( i, j );
  10. }
  11. }
  12. // 0 0
  13. // 0 1
  14. // 0 2
  15. // 0 3
  16. // 1 0
  17. // 1 1
  18. // 1 2
  19. // stopping! 1 3

注意: break foo不意味着“走到‘foo’标记的位置并继续”,而是,“跳出标记为‘foo’的循环/代码块儿,并继续它 后面 的部分”。不是一个传统意义上的goto,对吧?

对于上面的问题,使用不带标签的break将可能会牵连一个或多个函数,共享作用域中变量的访问,等等。它很可能要比带标签的break更令人糊涂,所以在这里使用带标签的break也许是更好的选择。

一个标签也可以用于一个非循环的块儿,但只有break可以引用这样的非循环标签。你可以使用带标签的break ___跳出任何被标记的块儿,但你不能continue ___一个非循环标签,也不能用一个不带标签的break跳出一个块儿。

  1. function foo() {
  2. // 用`bar`标记的块儿
  3. bar: {
  4. console.log( "Hello" );
  5. break bar;
  6. console.log( "never runs" );
  7. }
  8. console.log( "World" );
  9. }
  10. foo();
  11. // Hello
  12. // World

带标签的循环/块儿极不常见,而且经常使人皱眉头。最好尽可能地避开它们;比如使用函数调用取代循环跳转。但是也许在一些有限的情况下它们会有用。如果你打算使用标签跳转,那么就确保使用大量注释在文档中记下你在做什么!

一个很常见的想法是,JSON是一个JS的恰当子集,所以一个JSON字符串(比如{"a":42} —— 注意属性名周围的引号是JSON必需的!)被认为是一个合法的JavaScript程序。不是这样的! 如果你试着把{"a":42}敲进你的JS控制台,你会得到一个错误。

这是因为语句标签周围不能有引号,所以"a"不是一个合法的标签,因此:不能出现在它后面。

所以,JSON确实是JS语法的子集,但是JSON本身不是合法的JS文法。

按照这个路线产生的一个极其常见的误解是,如果你将一个JS文件加载进一个<script src=..>标签,而它里面仅含有JSON内容的话(就像从API调用中得到那样),这些数据将作为合法的JavaScript被读取,但只是不能从程序中访问。JSON-P(将JSON数据包进一个函数调用的做法,比如foo({"a":42}))经常被说成是解决了这种不可访问性,通过向你程序中的一个函数发送这些值。

不是这样的! 实际上完全合法的JSON值{"a":42}本身将会抛出一个JS错误,因为它被翻译为一个带有非法标签的语句块儿。但是foo({"a":42})是一个合法的JS,因为在它里面,{"a":42}是一个被传入foo(..)object字面量值。所以,更合适的说法是,JSON-P使JSON成为合法的JS文法!

块儿

另一个常为人所诟病的JS坑(与强制转换有关 —— 见第四章)是:

  1. [] + {}; // "[object Object]"
  2. {} + []; // 0

这看起来暗示着+操作符会根据第一个操作数是[]还是{}而给出不同的结果。但实际上这与它一点儿关系都没有!

在第一行中,{}出现在+操作符的表达式中,因此被翻译为一个实际的值(一个空object)。第四章解释过,[]被强制转换为""因此{}也会被强制转换为一个string"[object Object]"

但在第二行中,{}被翻译为一个独立的{}空代码块儿(它什么也不做)。块儿不需要分号来终结它们,所以这里缺少分号不是一个问题。最终,+ []是一个将[]明确强制转换number的表达式,而它的值是0

对象解构

从ES6开始,你将看到{ .. }出现的另一个地方是“解构赋值”(更多信息参见本系列的 ES6与未来),确切地说是object解构。考虑下面的代码:

  1. function getData() {
  2. // ..
  3. return {
  4. a: 42,
  5. b: "foo"
  6. };
  7. }
  8. var { a, b } = getData();
  9. console.log( a, b ); // 42 "foo"

正如你可能看出来的,var { a , b } = ..是ES6解构赋值的一种形式,它大体等价于:

  1. var res = getData();
  2. var a = res.a;
  3. var b = res.b;

注意: { a, b } 实际上是{ a: a, b: b }的ES6解构缩写,两者都能工作,但是人们期望短一些的{ a, b }能成为首选的形式。

使用一个{ .. }进行对象解构也可用于被命名的函数参数,这时它是同种类的隐含对象属性赋值的语法糖:

  1. function foo({ a, b, c }) {
  2. // 不再需要:
  3. // var a = obj.a, b = obj.b, c = obj.c
  4. console.log( a, b, c );
  5. }
  6. foo( {
  7. c: [1,2,3],
  8. a: 42,
  9. b: "foo"
  10. } ); // 42 "foo" [1, 2, 3]

所以,我们使用{ .. }的上下文环境整体上决定了它们的含义,这展示了语法和文法之间的区别。理解这些微妙之处以回避JS引擎进行意外的翻译是很重要的。

else if 和可选块儿

一个常见的误解是JavaScript拥有一个else if子句,因为你可以这么做:

  1. if (a) {
  2. // ..
  3. }
  4. else if (b) {
  5. // ..
  6. }
  7. else {
  8. // ..
  9. }

但是这里有一个JS文法隐藏的性质:它没有else if。但是如果附着在ifelse语句后面的代码块儿仅包含一个语句时,ifelse语句允许省略这些代码块儿周围的{ }。毫无疑问,你以前已经见过这种现象很多次了:

  1. if (a) doSomething( a );

许多JS编码风格指引坚持认为,你应当总是在一个单独的语句块儿周围使用{ },就像:

  1. if (a) { doSomething( a ); }

然而,完全相同的文法规则也适用于else子句,所以你经常编写的else if形式 实际上 被解析为:

  1. if (a) {
  2. // ..
  3. }
  4. else {
  5. if (b) {
  6. // ..
  7. }
  8. else {
  9. // ..
  10. }
  11. }

if (b) { .. } else { .. }是一个紧随着else的单独的语句,所以你在它周围放不放一个{ }都可以。换句话说,当你使用else if的时候,从技术上讲你就打破了那个常见的编码风格指导的规则,而且只是用一个单独的if语句定义了你的else

当然,else if惯用法极其常见,而且减少了一级缩进,所以它很吸引人。无论你用哪种方式,就在你自己的编码风格指导/规则中明确地指出它,并且不要臆测else if是直接的文法规则。