CategoryResourceRepost/极客时间专栏/JavaScript核心原理解析/从表达式到执行引擎:JavaScript是如何运行的/10 | x = yield x:迭代过程的“函数式化”.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

255 lines
15 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="10 | x = yield x迭代过程的“函数式化”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/c9/7a8d0796b78dfa1fbfe30f0c021945c9.mp3"></audio>
你好,我是周爱民。欢迎回到我的专栏。
相信上一讲的迭代过程已经在许多人心中留下了巨大的阴影,所以很多人一看今天的标题,第一个反应是:“又来!”
其实我经常习惯用**同一个例子**,或者**同类型示例的细微不同**去分辨与反映语言特性上的核心与本质的不同。如同在[第2讲](https://time.geekbang.org/column/article/165198)和[第3讲](https://time.geekbang.org/column/article/165985)中都在讲的连续赋值,看起来形似,却根本上不同。
同样,我想你可能也已经注意到了,在[第5讲](https://time.geekbang.org/column/article/167907)for (let x of [1,2,3]) ...)和[第9讲](https://time.geekbang.org/column/article/172636)(...x)中所讲述的内容是有一些相关性的。它们都是在讲循环。但第5讲主要讨论的是语句对循环的抽象和如何在循环中处理块。而第9讲则侧重于如何通过函数执行把类似第5讲的语句执行重新来实现一遍。事实上仅仅是一个“循环过程”在JavaScript中就实现了好几次。这些我将来都会具体地来为你分析。
至于今天,我还是回到函数的三个语义组件,也就是“参数、执行体和结果”来讨论。上一讲本质上讨论的是对“执行体”这个组件的重造,今天,则讨论对“参数和结果”的重构。
## 将迭代过程展开
通过上一讲,你应该知道迭代器是可以表达为一组函数的连续执行的。那么,如果我们要把这一组函数展开来看的话,其实它们之间的相似性是极强的。例如上一讲中提到的迭代函数`foo()`当你把它作为对象x的迭代器符号名属性并通过对象x来调用它的迭代展开事实上也就相当于只调用了多次的return语句。
```
// 迭代函数
function foo(x = 5) {
return {
next: () =&gt; {
return {done: !x, value: x &amp;&amp; x--};
}
}
}
let x = new Object;
x[Symbol.iterator] = foo; // default `x` is 5
console.log(...x);
```
事实上相当于只调用了5次return语句可以展开如下
```
// 上例在形式上可以表达为如下的逻辑
console.log(
/*return */{done: false, value: 5}.value,
/*return */{done: false, value: 4}.value,
/*return */{done: false, value: 3}.value,
/*return */{done: false, value: 2}.value,
/*return */{done: false, value: 1}.value
);
```
在形式上,类似上面这样的例子也可以展开来,表现它作为“多个值”的输出过程。
事实上连续的tor.next()调用最终仅是为了获取它们的值result.value那么如果封装这些值的生成过程就可以用一个新的函数来替代一批函数。
这样的一个函数就称为**生成器函数**。
但是由于函数只有一个出口RETURN所以用“函数的退出”是无法映射“**函数包含一个多次生成值的过程**”这样的概念的。如果要实现这一点,就必须让函数可以多次进入和退出。而这,就是今天这一讲的标题上的这个`yield` 运算符的作用。这些作用有两个方面:
1. 逻辑上它产生一次函数的退出并接受下一次tor.next()调用所需要的进入;
1. 数据上:它在退出时传出指定的值(结果),并在进入时携带传入的数据(参数)。
所以,`yield`实际上就是在生成器函数中用较少的代价来实现一个完整“函数执行”过程所需的“参数和结果”。而至于“执行体”这个组件如果你听过上一讲的话相信你已经知道了执行体就是tor.next()所推动的那个迭代逻辑。
例如,上面的例子用生成器来实现就是:
```
function *foo() {
yield 5;
yield 4;
yield 3;
yield 2;
yield 1;
}
```
或者更通用的过程:
```
function *foo2(x=5) {
while (x--) yield x;
}
// 测试
let x = new Object;
x[Symbol.iterator] = foo2; // default `x` is 5
console.log(...x); // 4 3 2 1 0
```
我们又看到了循环,尽管它被所谓的生成器函数封装了一次。
## 逻辑的重现
我想你已经注意到了,生成器的关键在于如何产生`yield`运算所需要的两个逻辑:(函数的)退出和进入。
事实上生成器内部是顺序的5行代码还是一个循环逻辑所以对于外部的使用者来说它是不可知的。生成器通过一个迭代器接口的界面与外部交互只要`for..of`或`...x`以及其他任何语法、语句或表达式识别该迭代器接口那么它们就可以用tor.next()以及result.done状态来组织外部的业务逻辑而不必界面后面的例如数据传入传出的细节了。
然而,对于生成器来说,“(函数的)退出和进入”是如何实现的呢?
在[第6讲](https://time.geekbang.org/column/article/168980)x: break x;)中提到过“**执行现场**”这个东西,事实上它包括三个层面的概念:
1. 块级作用域以及其他的作用域本质上就是一帧数据,交由所谓“环境”来管理;
1. 函数是通过CALL/RETURN来模拟上述“数据帧”在栈上的入栈与出栈过程也称为调用栈
1. 执行现场是上述环境和调用栈的一个瞬时快照(包括栈上数据的状态和执行的“位置”)。
其中的“位置”是一个典型的与“逻辑的执行过程”相关的东西第六讲中的“break”就主要在讲这个“位置”的控制——包括静态的标签以及标签在执行过程中所映射到的位置。
函数的进入CALL意味着数据帧的建立以及该数据帧压入调用栈而退出RETURN意味着它弹出栈和数据帧的销毁。从这个角度上来说`yield`运算必然不能使该函数退出(或者说必须不能让数据帧从栈上移除和销毁)。因为`yield`之后还有其他代码,而一旦数据帧销毁了,那么其他代码就无法执行了。
所以,`yield`是为数不多的能“挂起”当前函数的运算。但这并不是`yield`主要的、标志性的行为。`yield`操作最大的特点是**它在挂起当前函数时,还将函数所在栈上的执行现场移出了调用栈**。由于`yield`可以存在于生成器函数内的第n层作用域中。
```
function foo3() { // 块作用域1
if (true) { // 块作用域2
while (true) { // 块作用域3
yield 100
...
```
所以,一个在多级的块作用域深处的`yield`运算发生时需要向这个数据帧作用域链外层检索到第一个函数帧即函数环境FunctionEnvironment挂起它以及它内部的全部环境。而执行位置将会通过函数的调用关系一次性地返回到上一次tor.next()的下一行代码。也就是说相当于在tor.next()内部执行了一次`return`。
为了简化所谓“向外层检索”这一行为JavaScript通常是使用所谓“执行上下文”来管理这些数据帧环境与执行位置的。执行上下文与函数或代码块的词法上下文不同因为执行上下文只与“可执行体”相关是JavaScript引擎内部的数据结构它总是被关联且仅只关联到一个函数入口。
由于JavaScript引擎将JavaScript代码理解为函数因此事实上这个“执行上下文”能关联所有的用户代码文本。
“所有的代码文本”意味着“.js文件”的全局入口也会被封装成一个函数且全部的模块顶层代码也会做相同的封装。这样一来所有通过文件装载的代码文本都会只存在于同一个函数中。由于在Node.js或其他一些具体实现的引擎中无法同时使用标准的ECMAScript模块装载和.js文件装载因此事实上来说这些引擎在运行JavaScript代码时通常地也就只有一个入口的函数。
而所有的代码其实也就只运行在该函数的、唯一的一个“执行上下文”中。
如果用户代码——通过任意的手段——试图挂起这唯一的执行上下文那么也就意味着整个的JavaScript都停止了执行。因此“挂起”这个上下文的操作是受限制的被一系列特定的操作规范管理。这些规范我在这一讲的稍晚部分内容中会详细讲述但这里我们先关注一个关键问题到底有多少个执行上下文
如果模块与文件装载机制分开,那么模块入口和文件入口就是二选一的。当然在不同的引擎中这也不尽相同,只是在这里分开讨论会略为清晰一些。
**模块入口**是所有模块的顶层代码的顺序组合它们被封装为一个称为“顶层模块执行TopLevelModule Evaluation Job”的函数作为模块加载的第一个执行上下文创建。类似的是一般的.js文件装载也会创建一个称为“脚本执行Script EvaluationJob”的函数。后者也是文件加载中所有全局代码块称为“Script块”的原因。
除了这两种执行上下文之外eval()总是会开启一个执行上下文的。
JavaScript为eval()所分配的这个执行上下文与调用eval()时的函数上下文享有同一个环境包括词法环境和变量环境等等并在退出eval()时释放它的引用,以确保同一个环境中“同时”只有一个逻辑在执行。
接下来如果一个一般函数被调用那么它也将形成一个对应的执行上下文但是由于这个上下文是“被”调用而产生的所以它会创建一个“调用者caller”函数的上下文的关联并创建在caller之后。由于栈是后入先出的结构因此总是立即执行这个“被调用者callee”函数的上下文。
这也是调用栈入栈“等义于”调用函数的原因。
但这个过程也就意味着这个“当前的活动的”调用栈是由一系列执行上下文以及它们所包含的数据帧所构成的。而且就目前来说这个调用栈的底部要么是模块全局_TopLevelModuleEvaluationJob_任务要么就是脚本全局_ScriptEvaluationJob_任务
一旦你了解了这些,那么你就很容易理解生成器的特殊之处了:
所有其他上下文都在执行栈上,而生成器的上下文(多数时间是)在栈的外面。
## 有趣的.next()方法
如果有一行`yield`代码出现在生成器函数中,那么当这个生成器函数执行到`yield`表达式时会发生什么呢?
这个问题貌似不好回答,但是如果问:是什么让这个生成器函数执行到“`yeild`表达式”所在位置的呢这个问题就好回答了是tor.next()方法。如下例:
```
function* foo3() {
yield 10;
}
let tor = foo3();
...
```
我们可以简单地写一个生成器函数`foo3()`,它的内部只有一行`yield`代码。在这样的一个示例中调用foo3()函数之后你就已经获得了来自foo3()的一个迭代器对象在习惯上的我称它为tor。并且在语法形式上貌似foo3()函数已经执行了一次。
但是事实上foo3()所声明的函数体并没有执行(因为它是生成器函数),而是直到用户代码调用`tor.next()`的时候foo3()所声明的函数体才正式执行并直到那唯一的一行代码:表达式`yeild`。
```
# 调用迭代器方法
&gt; tor.next()
{ value: 10, done: false }
```
这时foo3()所声明的函数体正式执行,并直到表达式`yeild 10`,生成器函数才返回了第一个值`10`。
如同上一讲中所说到的,这表明在代码`tor = foo3()`中函数调用“foo3()”的实际执行效果是生成一个迭代过程并将该过程交给了tor对象。
换而言之tor是foo3()生成器内部的迭代过程的一个句柄。从引擎内的实现过程来说tor其实包括状态state和执行上下文context两个信息它是`GeneratorFunction.prototype`的一个实例。这个tor所代表的生成器在创建出来的时候将立即被挂起因此状态值state初始化置为"启动时挂起suspendedStart"而当在调用tor.next()因`yield`运算而导致的挂起称为"Yield时挂起suspendedYield"。
另一个信息即context就指向tor被创建时的上下文。上面已经说过了所谓上下文一定指的是一个外部的、内部的或由全局/模块入口映射成的函数。
接下来当tor.next()执行时tor所包括的context信息被压到栈顶执行当tor.next()退出时这个context就被从栈上移除。这个过程与调用eval()是类似的,总是能保证指定栈是全局唯一活动的一个栈。
如果活动栈唯一,那么系统就是同步的。
因为只需要一个执行线程。
## 对传入参数的改造
生成器对“函数执行”的执行体加以改造使之变成由tor.next()管理的多个片断。用来映射多次函数调用的“每个body”。除此之外它还对传入参数加以改造使执行“每个body”时可以接受不同的参数。这些参数是通过tor.next()来传入并作为yield运算的结果而使用的。
这里JavaScript偷偷地更换了概念。也就是说 在:
```
x = yield x
```
这行表达式中,从语法上看是表达式`yield x`求值,实际的执行效果是:
- `yield`向函数外发送计算表达式`x`的值;
`x = ...`的赋值语义变成了:
- `yield`接受外部传入的参数并作为结果赋给x。
将tor.next()联合起来看由于tor所对应的上下文在创建后总是挂起的因此第一个tor.next()调用总是将执行过程“推进”到第一行`yield`并挂起。例如:
```
function* foo4(x=5) {
console.log(x--); // `tor = foo4()`时传入的值5
// ...
x = yield x; // 传出`x`的值
console.log(x); // 传入的arg
// ...
}
let tor = foo4(); // default `x` is 5
result = tor.next(); // 第一次调用.next()的参数将被忽略
console.log(result.value)
```
执行结果将显示:
```
5 // &lt;- defaut `5`
4 // &lt;- result.value `4`
```
而foo4()函数在`yield`表达式执行后将挂起。而当在下一次调用tor.next(arg)时,一个已经被`yield`挂起的生成器将恢复resume这时传入的参数arg就将作为`yield`表达式在它的上下文中的结果。也就是上例中第二个console.log(x)中的`x`值。例如:
```
# 传入100将作为foo4()内的yield表达式求值结果赋给`x = ...`
&gt; tor.next(100)
100
```
## 知识回顾
今天这一讲,谈的是将迭代过程展开并重新组织它的语义,然后变成生成器与`yield`运算的全过程。
在这个过程中你需要关注的是JavaScript对“迭代过程”展开之后的代码体和参数处理。
事实上,这包含了对函数的全部三个组件的重新定义:代码体、参数传入、值传出。只不过,在`yield`中尤其展现了对传入传出的处理而已。
## 思考题
今天的这一讲不安排什么特别的课后思考我希望你能补充一下一个小知识点的内容由于今天的内容中没有讲“委托的yield”这个话题因此你可以安排一些时间查阅资料对这个运算符——也就是“yeild*”的实现过程和特点做一些深入探索。
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。