CategoryResourceRepost/极客时间专栏/JavaScript核心原理解析/从表达式到执行引擎:JavaScript是如何运行的/09 | (...x):不是表达式、语句、函数,但它却能执行.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

387 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="09 | (...x):不是表达式、语句、函数,但它却能执行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/65/e4d105b8e77cf7ff7010baf1ced35465.mp3"></audio>
你好,我是周爱民,欢迎回到我的专栏。
从之前的课程中,你应该已经对语句执行和函数执行有了基本的了解。事实上,这两种执行其实都是对**顺序**、**分支**与**循环**三种逻辑在语义上的表达。
也就是说,不论一门语言的语法有什么特异之处,它对“执行逻辑”都可以归纳到这三种语义的表达方式上来。这种说法事实上也并不特别严谨,因为这三种基本逻辑还存在进一步抽象的空间(这些也会是我将来会讨论到的内容,今天暂且不论)。
今天这一讲中,主要讨论的是第二种执行的一些细节,也就是对“函数执行”的进一步补充。
在上一讲中,我有意将函数分成三个语义组件来讲述。我相信在绝大多数的情况下,或者在绝大多数的语言教学中,都是不必要这样做的。这三个语义组件分别是指参数、执行体和结果,将它们分开来讨论,最主要的价值就在于:通过改造这三个语义组件的不同部分,我们可以得到不同的“函数式的”执行特征与效果。换而言之,可以通过更显式的、特指的或与应用概念更贴合的语法来表达新的语义。与所谓“特殊可执行结构”一样,这些语义也用于映射某种固定的、确定的逻辑。
语言的设计,本质就是为确定的语义赋以恰当的语法表达。
## 递归与迭代
如果循环是一种确定的语义,那么如何在函数执行中为它设计合适的语法表达呢?
递归绝对是一个好的、经典的求解思路。递归将循环的次数直接映射成函数“执行体”的重复次数,将循环条件放在函数的参数界面中,并通过函数调用过程中的值运算来传递循环次数之间的数值变化。递归作为语义概念简单而自然,唯一与函数执行存在(潜在的)冲突的只是所谓栈的回收问题,亦即是尾递归的处理技巧等,但这些都是实现层面的要求,而与语言设计无关。
由于递归并不改变函数的三个语义组件中的任何一个,因此它与函数执行过程完全没有冲突,也没有任何新的需求与设计。这句话的潜在意思是说,函数的三个语义组件都不需要为此作出任何的设计修改,例如:
```
const f = x =&gt; x &amp;&amp; f(--x);
```
在这段代码中,是没有出现任何特殊的语法和运算/操作符的,它只是对函数、(变量或常量的)声明、表达式以及函数调用等等的简单组合。
然而迭代不是。迭代也是循环语义的一种实现,它说明循环是“函数体”的重复执行,而不是“递归”所理解的“函数调用自己”的语义。这是一种可受用户代码控制的循环体。你可以尝试创建这样一个简单的迭代函数:
```
// 迭代函数
function foo(x = 5) {
return {
next: () =&gt; {
return {done: !x, value: x &amp;&amp; x--};
}
}
}
```
然而请仔细观察这样的两个实现,你需要注意在这个迭代函数中有“值(value)和状态(done)”两个控制变量并且它的实际执行代码与上面的函数f()是一样的:
```
// in 函数f()
x &amp;&amp; f(--x)
// in 迭代foo()
x &amp;&amp; x--
```
也就是说,递归函数“**f()**”和迭代函数“foo()”其实是在实现相同的过程。只是由于“递归完成与循环过程的结束”在这里是相同的语义,因此函数“**f()**”中不需要像迭代函数那样来处理“状态(done)”的传出。递归函数“**f()**”,要么结束,要么无穷递归。
## 迭代对执行过程的重造和使用
在JavaScript中是通过一个中间对象来使用迭代过程_foo()_的。该中间对象称为迭代器foo()称为迭代器函数,用于返回该迭代器。例如:
```
var tor = foo(); // default `x` is 5
...
```
迭代器具有`.next()`方法用于一次(或每次)迭代调用。由于没有约定迭代调用的方式,因此可以用任何过程来调用它。例如:
```
// 在循环语句中处理迭代调用
var tor = foo(5), result = tor.next();
while (!result.done) {
console.log(result.value);
result = tor.next();
}
```
除了一些简单的、概念名词上的置换外,这些与你所见过的绝大多数有关“迭代器与生成器”的介绍并没有什么不同。并且你也应当理解,正是这个“.next()”调用的界面维护了迭代过程的上下文,以及值之间的相关性(例如一个值序列的连续性)。
根据约定如果有一个对象“包含”这样一个迭代器函数以返回一个迭代器那么这个对象就是可迭代的。基于JavaScript中“对象是属性集所以所有包含的东西都必然是属性”的概念这个迭代函数被设计为称为“Symbol.iterator”的符号属性。例如
```
let x = new Object;
x[Symbol.iterator] = foo; // default `x` is 5
```
现在,你可以使用这个可迭代对象了:
```
&gt; console.log(...x);
5 4 3 2 1
```
现在,你看到了这一讲标题中的代码:
```
(...x)
```
不过,不同的是,标题中的代码是不能执行的。
## 展开语法
问题的关键点在于:`...x`是什么?
在形式上,“…”看起来像是一个运算符,而`x`是它的操作数。但是,如果稍微深入地问一下这个问题,就会令人生疑了。例如:如果它是运算符,那么运算的返回值是什么?
答案是,它既不返回值,也不返回引用。
那么如果它不是运算符,或者说`...x`也并不是表达式,或许它们可以被理解为“语句”吗?即使如此,与上面相同的问题也会存在。例如:如果它是语句,那么该语句的返回值是什么?
答案是既不是空Empty也不是其它结果Result。因此它也不是语句并且因为console.log()是表达式,而表达式显然也“不可能包含语句”)。
所以,`...x`既不是表达式也不是语句。它不是我们之前讲过的任何一种概念而仅仅只是“语法”。作为语法ECMAScript在这里规定它只是对一个确定的语义的封装。
在语义上,它用于“展开一个可迭代对象”。
## 如何做到呢?
为什么我要绕这么大个圈子来介绍这个“简单的”展开语法呢又或者说ECMAScript为什么要弄出这么一个“新”概念呢
这与函数的第三个语义组件——“值”是有关的。在JavaScript中也包括在绝大多数支持函数的语言中函数只能返回一个值。然而如果迭代器表达的是一个重复执行的执行体并且每次执行都返回一个值那么又怎么可能用“返回一个值”的函数来返回呢
与此类似,语句也只有一个这样的单值返回,所以批语句执行也仍然只是返回最后一行的结果。并且,一旦`...x`被理解为语句,那么它就不能用作操作数,成为一个表达式的部分。这在概念上是不容许的。所以,当在“函数”这个级别表达多次调用时,尽管它可以通过“对象(迭代对象)”来做形式上的封装,却无法有效地表达“多次调用的多个结果”。这才是展开语法被设计出来的原因。
如果可迭代对象表达的是“多个值”那么可以作用于它的操作或运算通常应该是那些面向“值的集合Collections”的。更确切地说它是可以面向“索引集合Indexed Collections”和“键值集合Keyed Collections”设计的语法概念。因此在现在的以及将来的ECMAScript规范中你将会看到它的操作例如通常包括的合并、映射、筛选等等将在包括对象、数组、集Set、图Map等等数据的处理中大放异彩。
而现在,其实我想问的问题是,在函数中是如何做到迭代处理的呢?
## 内部迭代过程
迭代的本质是多次函数调用在JavaScript内部实现这一机制本质上就是管理这些多次调用之间的关系。这显然包括一个循环过程和至少一个循环控制变量。
这个迭代有一个开启过程简单的如展开语法“…”复杂的如for…of语句。这些语法/语法结构通过类似如下两个步骤来完成迭代的开启:
```
var tor = foo(5), result = tor.next();
while (!result.done) ...
```
但是如同我在之前的课程,以及上面的讨论中一再强调的这是“一个执行过程”,既然是过程,那么就存在过程被中断的可能。简单的示例如下:
```
while (!result.done) {
break;
}
```
是的这个过程什么也不会发生。如果是在经典的while循环里面那么它的result和tor以及foo()调用所开启的那个函数闭包都被当前上下文管理或回收。然而如果在一个展开过程或者for…of循环中相应的“语法”管理上述这些组件的时候又需要怎样的处理呢例如
```
function touch(x) {
if (x==2) throw new Error(&quot;hard break&quot;);
}
// 迭代函数
function foo2(x = 5) {
return {
next: () =&gt; {
touch(x); // some process methods
return {done: !x, value: x &amp;&amp; x--};
}
}
}
// 示例
let x = new Object;
x[Symbol.iterator] = foo2; // default `x` is
```
测试如下:
```
&gt; console.log(...x);
Error: hard break
```
这个示例是一个简单异常但如果这个异常发生于for…of中
```
&gt; for (let i of x) console.log(i);
5
4
3
Error: hard break
```
在这两种示例中异常都是发生于foo2()这个函数调用的一个外部处理过程中而等到用户代码有机会操作时已经处于console.log()调用或for…of循环中了如果用户在这里设计异常处理过程那么foo2()中的touch(x)管理和涉及的资源都无法处理。因此ECMAScript设计了另外两个方法来确保foo2()中的代码在“多次调用”中仍然是受控的。这包括两个回调方法:
>
<p>tor.return(),当迭代正常过程退出时回调<br>
tor.throw(),当迭代过程异常退出时回调</p>
这并不难于证实:
```
&gt; Object.getOwnPropertyNames(tor.constructor.prototype)
[ 'constructor', 'next', 'return', 'throw' ]
```
现在如果给tor的return属性加一个回调函数会发生什么呢
```
// 迭代函数
function foo2(x = 5) {
return {
// 每次.next()都不会返回done状态因此可列举无穷次
&quot;next&quot;: () =&gt; new Object, // result instance, etc.
&quot;return&quot;: () =&gt; console.log(&quot;RETURN!&quot;)
}
}
let x = new Object;
x[Symbol.iterator] = foo2; // default `x` is 5
```
测试一下:
```
# 列举x第一次迭代后即执行break;
&gt; for (let i of x) break;
RETURN!
```
结果是`RETURN!`
什么鬼?!
## 异常处理
并且如果你试图在tor.throw中去响应foo()迭代中的异常,却什么也得不到。例如:
```
// 迭代函数
function foo3(x = 5) {
return {
// 第一个.next()执行时即发生异常
&quot;next&quot;: () =&gt; { throw new Error },
&quot;throw&quot;: () =&gt; console.log(&quot;THROW!&quot;)
}
}
let x = new Object;
x[Symbol.iterator] = foo3;
```
在测试中,异常直接被抛给了全局:
```
&gt; console.log(...x);
Error
at Object.next (repl:4:27)
```
继续显然可以把这个例子跟最开始使用的foo()组合起来foo()迭代可以正确地得到`5 4 3 2 1`而上面的return/throw可以捕获过程的退出或异常。例如
```
// 迭代函数
function foo4(x = 5) {
return {
// foo()中的next
next: () =&gt; {
return {done: !x, value: x &amp;&amp; x--};
},
// foo2()和foo3()中的return和throw
&quot;return&quot;: () =&gt; console.log(&quot;RETURN!&quot;),
&quot;throw&quot;: () =&gt; console.log(&quot;THROW!&quot;)
}
}
let x = new Object;
x[Symbol.iterator] = foo4
```
测试:
```
&gt;&gt;console.log(...x);
5 4 3 2 1
```
Ok成功是成功了但是“RETURN/THROW“呢
这里简直就是迭代的地狱!
## 是谁的退出与异常?
回顾之前的内容迭代过程并不是一个语法执行的过程而是应该理解为一组函数执行的过程对于这一批函数执行过程中的结束行为也应该理解为函数内的异常或退出。因此尽管在for…of的表面上看是break发生了语句中的中止而在迭代处理的内部发生的却是“一个迭代过程的退出”。与此同样复杂的是在这一批函数的多个执行上下文中不论是在哪儿发生了异常其实只有外层的第一个能捕获异常的环境能响应这个异常。
简单地说:“退出(RETURN)”是执行过程的,“异常(THROW)”是外部的。
JavaScript中迭代被处理为两个实现用的组件一个是循环的迭代过程另一个是循环的迭代控制变量。表现在tor这个迭代对象上来看就是对于循环来说“如果谁使用迭代变量tor那么就是谁管理迭代过程”。
这个“管理循环过程”意味着:
- 如果迭代结束不论它因为什么结束那么触发tor.return事件
- 如果发现异常只要是当前环境能捕获到的异常那么触发tor.throw事件。
这两个过程总是发生在“管理循环过程”的行为框架中。例如在下面这个过程中:
```
for (let i of x) {
if (i == 2) break;
}
```
由于 `for .. of`语句将获得x对象的迭代变量tor那么它也将管理x对象的迭代过程。因此在for语句break之后在for语句将会退出自己的作用之前它也就必须去“通知”x对象迭代过程也结束了于是这个语句触发了tor.return事件。
同样,如果是一个数组展开过程:
```
console.log(...x);
```
那么将是`...x`这个“展开语法”来负责上述的迭代过程的管理和“通知”,这个语法在它所在的位置上是无法响应异常的。该语法所在位置是一个表达式,不可能在它内部使用`try..catch`语句。
```
function touch(x) {
if (x==2) throw new Error(&quot;hard break&quot;);
}
// 迭代函数
function foo5(x = 5) {
return {
// foo2()中的next
next: () =&gt; {
touch(x); // some process methods
return {done: !x, value: x &amp;&amp; x--};
},
// foo3()中的return和throw
&quot;return&quot;: () =&gt; console.log(&quot;RETURN!&quot;),
&quot;throw&quot;: () =&gt; console.log(&quot;THROW!&quot;)
}
}
let x = new Object;
x[Symbol.iterator] = foo5;
try {
console.log(...x);
}
catch(e) {} // m
```
这段示例代码将mute掉一切既没有console.log()输出也没有异常信息tor的return/throw一个也没有发生。
对于x这个可迭代对象以及foo5()这个迭代器函数来说,世界是安静的:它既不知道自己发生了什么,也不知道它的外部世界发生了什么。因为`...x`这个语法既没有管理迭代过程因此不理解tor的退出/return行为也没有在异常发生时向内“通知”tor.throw事件的能力。
## 知识回顾
标题中的示例是不能执行的,因为其中的括号并不是表达式中分组运算符,也不是语句中的函数调用,也不是声明中的形式参数表。声明中的`...x`被定义为“展开语法”是逻辑的映射它返回的是处理逻辑而不是“值”或“引用”。它在不同的位置被JavaScript解释成不同的语义包括对象展开和数组展开并通过一组特定的代码来实现上述的语义。
在`...x`被理解为数组展开时,本质上是将`x`视为一个可迭代对象并通过一个迭代变量例如tor来管理它的迭代过程。在JavaScript中的迭代对象x的生存周期是交由使用它的表达式、语句或语法来管理的包括在必要的时候通过tor来向内通知return/throw事件。
在本讲的示例中展开语法“…x”是没有向内通知的能力的而“for … of”可以隐式地向内通知。对于后者for…of中的break和continue以及循环的正常退出都能够通知return事件但它并没有内向通知throw的能力因为for…of语句本身并不捕获和处理throw。
## 思考题
- 既然上面的过程完全不使用tor.throw那么它被设计出来做什么
- `...x`为什么称为“展开语法”为什么ECMAScript不提供一个表达式/语句之外的概念来指代它?
- continue在那种情况下触发tor.return
- yield* x将导致什么
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。