第四章: Generator - 打破运行至完成

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

打破运行至完成

在第一章中,我们讲解了一个JS开发者们在他们的代码中几乎永恒依仗的一个认识:一旦函数开始执行,它将运行直至完成,没有其他的代码可以在运行期间干扰它。

这看起来可能很滑稽,ES6引入了一种新型的函数,它不按照“运行至完成”的行为进行动作。这种新型的函数称为“generator(生成器)”。

为了理解它的含义,让我们看看这个例子:

  1. var x = 1;
  2. function foo() {
  3. x++;
  4. bar(); // <-- 这一行会发生什么?
  5. console.log( "x:", x );
  6. }
  7. function bar() {
  8. x++;
  9. }
  10. foo(); // x: 3

在这个例子中,我们确信bar()会在x++console.log(x)之间运行。但如果bar()不在这里呢?很明显结果将是2而不是3

现在让我们来燃烧你的大脑。要是bar()不存在,但以某种方式依然可以在x++console.log(x)语句之间运行呢?这可能吗?

抢占式(preemptive) 多线程语言中,bar()去“干扰”并正好在两个语句之间那一时刻运行,实质上时可能的。但JS不是抢占式的,也(还)不是多线程的。但是,如果foo()本身可以用某种办法在代码的这一部分指示一个“暂停”,那么这种“干扰”(并发)的 协作 形式就是可能的。

注意: 我使用“协作”这个词,不仅是因为它与经典的并发术语有关联(见第一章),也因为正如你将在下一个代码段中看到的,ES6在代码中指示暂停点的语法是yield——暗示一个让出控制权的礼貌的 协作

这就是实现这种协作并发的ES6代码:

  1. var x = 1;
  2. function *foo() {
  3. x++;
  4. yield; // 暂停!
  5. console.log( "x:", x );
  6. }
  7. function bar() {
  8. x++;
  9. }

注意: 你将很可能在大多数其他的JS文档/代码中看到,一个generator的声明被格式化为function* foo() { .. }而不是我在这里使用的function *foo() { .. }——唯一的区别是摆放*位置的风格。这两种形式在功能性/语法上是完全一样的,还有第三种function*foo() { .. }(没空格)形式。这两种风格存在争议,但我基本上偏好function *foo..,因为当我在写作中用*foo()引用一个generator时,这种形式可以匹配我写的东西。如果我只说foo(),你就不会清楚地知道我是在说一个generator还是一个一般的函数。这纯粹是一个风格偏好的问题。

现在,我们该如何运行上面的代码,使bar()yield那一点取代*foo()的执行?

  1. // 构建一个迭代器`it`来控制generator
  2. var it = foo();
  3. // 在这里开始`foo()`!
  4. it.next();
  5. x; // 2
  6. bar();
  7. x; // 3
  8. it.next(); // x: 3

好了,这两段代码中有不少新的,可能使人困惑的东西,所以我们得跋涉好一段了。在我们用ES6的generator来讲解不同的机制/语法之前,让我们过一遍这个行为的流程:

  1. it = foo()操作 不会 执行*foo()generator,它只不过构建了一个用来控制它执行的 迭代器(iterator)。我们一会更多地讨论 迭代器
  2. 第一个it.next()启动了*foo()generator,并且运行*foo()第一行上的x++
  3. *foo()yield语句处暂停,就在这时第一个it.next()调用结束。在这个时刻,*foo()依然运行而且是活动的,但是处于暂停状态。
  4. 我们观察x的值,现在它是2.
  5. 我们调用bar(),它再一次用x++递增x
  6. 我们再一次观察x的值,现在它是3
  7. 最后的it.next()调用使*foo()generator从它暂停的地方继续运行,而后运行使用x的当前值3console.log(..)语句。

清楚的是,*foo()启动了,但 没有 运行到底——它停在yield。我们稍后继续*foo(),让它完成,但这甚至不是必须的。

所以,一个generator是一种函数,它可以开始和停止一次或多次,甚至没必要一定要完成。虽然为什么它很强大看起来不那么明显,但正如我们将要在本章剩下的部分将要讲到的,它是我们用于在我们的代码中构建“generator异步流程控制”模式的基础构建块儿之一。

输入和输出

一个generator函数是一种带有我们刚才提到的新型处理模型的函数。但它仍然是一个函数,这意味着依旧有一些不变的基本原则——即,它依然接收参数(也就是“输入”),而且它依然返回一个值(也就是“输出”):

  1. function *foo(x,y) {
  2. return x * y;
  3. }
  4. var it = foo( 6, 7 );
  5. var res = it.next();
  6. res.value; // 42

我们将67分别作为参数xy传递给*foo(..)。而*foo(..)将值42返回给调用端代码。

现在我们可以看到发生器的调用和一般函数的调用的一个不同之处了。foo(6,7)显然看起来很熟悉。但微妙的是,*foo(..)generator不会像一个函数那样实际运行起来。

相反,我们只是创建了 迭代器 对象,将它赋值给变量it,来控制*foo(..)generator。当我们调用it.next()时,它指示*foo(..)generator从现在的位置向前推进,直到下一个yield或者generator的最后。

next(..)调用的结果是一个带有value属性的对象,它持有从*foo(..)返回的任何值(如果有的话)。换句话说,yield导致在generator运行期间,一个值被从中发送出来,有点儿像一个中间的return

但是,为什么我们需要这个完全间接的 迭代器 对象来控制generator还不清楚。我们回头会讨论它的,我保证。

迭代通信

generator除了接收参数和拥有返回值,它们还内建有更强大,更吸引人的输入/输出消息能力,这是通过使用yieldnext(..)实现的。

考虑下面的代码:

  1. function *foo(x) {
  2. var y = x * (yield);
  3. return y;
  4. }
  5. var it = foo( 6 );
  6. // 开始`foo(..)`
  7. it.next();
  8. var res = it.next( 7 );
  9. res.value; // 42

首先,我们将6作为参数x传入。之后我们调用it.next(),它启动了*foo(..).

*foo(..)内部,var y = x ..语句开始被处理,但它运行到了一个yield表达式。就在这时,它暂停了*foo(..)(就在赋值语句的中间!),而且请求调用端代码为yield表达式提供一个结果值。接下来,我们调用it.next(7),将7这个值传回去作为暂停的yield表达式的结果。

所以,在这个时候,赋值语句实质上是var y = 6 * 7。现在,return y将值42作为结果返回给it.next( 7 )调用。

注意一个非常重要,而且即便是对于老练的JS开发者也非常容易犯糊涂的事情:根据你的角度,在yieldnext(..)调用之间存在着错位。一般来说,你所拥有的next(..)调用的数量,会比你所拥有的yield语句的数量多一个——前面的代码段中有一个yield和两个next(..)调用。

为什么会有这样的错位?

因为第一个next(..)总是启动一个generator,然后运行至第一个yield。但是第二个next(..)调用满足了第一个暂停的yield表达式,而第三个next(..)将满足第二个yield,如此反复。

两个疑问的故事

实际上,你主要考虑的是哪部分代码会影响你是否感知到错位。

仅考虑generator代码:

  1. var y = x * (yield);
  2. return y;

第一个 yield基本上是在 问一个问题:“我应该在这里插入什么值?”

谁来回答这个问题?好吧,第一个 next()在这个时候已经为了启动generator而运行过了,所以很明显 不能回答这个问题。所以,第二个 next(..)调用必须回答由 第一个 yield提出的问题。

看到错位了吧——第二个对第一个?

但是让我们反转一下我们的角度。让我们不从generator的角度看问题,而从迭代器的角度看。

为了恰当地描述这种角度,我们还需要解释一下,消息可以双向发送——yield ..作为表达式可以发送消息来应答next(..)调用,而next(..)可以发送值给暂停的yield表达式。考虑一下这段稍稍调整过的代码:

  1. function *foo(x) {
  2. var y = x * (yield "Hello"); // <-- 让出一个值!
  3. return y;
  4. }
  5. var it = foo( 6 );
  6. var res = it.next(); // 第一个`next()`,不传递任何东西
  7. res.value; // "Hello"
  8. res = it.next( 7 ); // 传递`7`给等待中的`yield`
  9. res.value; // 42

yield ..next(..)一起成对地 在generator运行期间 构成了一个双向消息传递系统。

那么,如果只看 迭代器 代码:

  1. var res = it.next(); // 第一个`next()`,不传递任何东西
  2. res.value; // "Hello"
  3. res = it.next( 7 ); // 传递`7`给等待中的`yield`
  4. res.value; // 42

注意: 我们没有传递任何值给第一个next()调用,而且是故意的。只有一个暂停的yield才能接收这样一个被next(..)传递的值,但是当我们调用第一个next()时,在generator的最开始并 没有任何暂停的yield 可以接收这样的值。语言规范和所有兼容此语言规范的浏览器只会无声地 丢弃 任何传入第一个next()的东西。传递这样的值是一个坏主意,因为你只不过创建了一些令人困惑的无声“失败”的代码。所以,记得总是用一个无参数的next()来启动generator。

第一个next()调用(没有任何参数的)基本上是在 问一个问题:“*foo(..)generator将要给我的 下一个 值是什么?”,谁来回答这个问题?第一个yield表达式。

看到了?这里没有错位。

根据你认为是 在问问题,在yieldnext(..)之间的错位既存在又不存在。

但等一下!跟yield语句的数量比起来,还有一个额外的next()。那么,这个最后的it.next(7)调用又一次在询问generator 下一个 产生的值是什么。但是没有yield语句剩下可以回答了,不是吗?那么谁来回答?

return语句回答这个问题!

而且如果在你的generator中 没有return——比起一般的函数,generator中的return当然不再是必须的——总会有一个假定/隐式的return;(也就是return undefined;),它默认的目的就是回答由最后的it.next(7)调用 提出 的问题。

这些问题与回答——用yieldnext(..)进行双向消息传递——十分强大,但还是看不出来这些机制与异步流程控制有什么联系。我们正在接近真相!

多迭代器

从语法使用上来看,当你用一个 迭代器 来控制generator时,你正在控制声明的generator函数本身。但这里有一个容易忽视的微妙细节:每当你构建一个 迭代器,你都隐含地构建了一个将由这个 迭代器 控制的generator的实例。

你可以让同一个generator的多个实例同时运行,它们甚至可以互动:

  1. function *foo() {
  2. var x = yield 2;
  3. z++;
  4. var y = yield (x * z);
  5. console.log( x, y, z );
  6. }
  7. var z = 1;
  8. var it1 = foo();
  9. var it2 = foo();
  10. var val1 = it1.next().value; // 2 <-- 让出2
  11. var val2 = it2.next().value; // 2 <-- 让出2
  12. val1 = it1.next( val2 * 10 ).value; // 40 <-- x:20, z:2
  13. val2 = it2.next( val1 * 5 ).value; // 600 <-- x:200, z:3
  14. it1.next( val2 / 2 ); // y:300
  15. // 20 300 3
  16. it2.next( val1 / 4 ); // y:10
  17. // 200 10 3

警告: 同一个generator的多个并发运行实例的最常见的用法,不是这样的互动,而是generator在没有输入的情况下,从一些连接着的独立资源中产生它自己的值。我们将在下一节中更多地讨论产生值。

让我们简单地走一遍这个处理过程:

  1. 两个*foo()在同时启动,而且两个next()都分别从yield 2语句中得到了2value
  2. val2 * 10就是2 * 10,它被发送到第一个generator实例it1,所以x得到值20z1递增至2,然后20 * 2yield出来,将val1设置为40
  3. val1 * 5就是40 * 5,它被发送到第二个generator实例it2中,所以x得到值200z又一次递增,从23,然后200 * 3yield出来,将val2设置为600
  4. val2 / 2就是600 / 2,它被发送到第一个generator实例it1,所以y得到值300,然后分别为它的x y z值打印出20 300 3
  5. val1 / 4就是40 / 4,它被发送到第一个generator实例it2,所以y得到值10,然后分别为它的x y z值打印出200 10 3

这是在你脑海中跑过的一个“有趣”的例子。你还能保持清醒?

穿插

回想第一章中“运行至完成”一节的这个场景:

  1. var a = 1;
  2. var b = 2;
  3. function foo() {
  4. a++;
  5. b = b * a;
  6. a = b + 3;
  7. }
  8. function bar() {
  9. b--;
  10. a = 8 + b;
  11. b = a * 2;
  12. }

使用普通的JS函数,当然要么是foo()可以首先运行完成,要么是bar()可以首先运行至完成,但是foo()不可能与bar()穿插它的独立语句。所以,前面这段代码只有两个可能的结果。

然而,使用generator,明确地穿插(甚至是在语句中间!)是可能的:

  1. var a = 1;
  2. var b = 2;
  3. function *foo() {
  4. a++;
  5. yield;
  6. b = b * a;
  7. a = (yield b) + 3;
  8. }
  9. function *bar() {
  10. b--;
  11. yield;
  12. a = (yield 8) + b;
  13. b = a * (yield 2);
  14. }

根据 迭代器 控制*foo()*bar()分别以什么样的顺序被调用,前面这段代码可以产生几种不同的结果。换句话说,通过两个generator在同一个共享的变量上穿插,我们实际上可以展示(以一种模拟的方式)在第一章中讨论的,理论上的“线程的竞合状态”环境。

首先,让我们制造一个称为step(..)的帮助函数,让它控制 迭代器

  1. function step(gen) {
  2. var it = gen();
  3. var last;
  4. return function() {
  5. // 不论`yield`出什么,只管在下一次时直接把它塞回去!
  6. last = it.next( last ).value;
  7. };
  8. }

step(..)初始化一个generator来创建它的it 迭代器,然后它返回一个函数,每次这个函数被调用时,都将 迭代器 向前推一步。另外,前一个被yield出来的值将被直接发给下一步。所以,yield 8将变成8yield b将成为b(不管它在yield时是什么值)。

现在,为了好玩儿,让我们做一些实验,来看看将这些*foo()*bar()的不同块儿穿插时的效果。我们从一个无聊的基本情况开始,保证*foo()*bar()之前全部完成(就像我们在第一章中做的那样):

  1. // 确保重置了`a`和`b`
  2. a = 1;
  3. b = 2;
  4. var s1 = step( foo );
  5. var s2 = step( bar );
  6. // 首先完全运行`*foo()`
  7. s1();
  8. s1();
  9. s1();
  10. // 现在运行`*bar()`
  11. s2();
  12. s2();
  13. s2();
  14. s2();
  15. console.log( a, b ); // 11 22

最终结果是1122,就像第一章的版本那样。现在让我们把顺序混合穿插,来看看它如何改变ab的值。

  1. // 确保重置了`a`和`b`
  2. a = 1;
  3. b = 2;
  4. var s1 = step( foo );
  5. var s2 = step( bar );
  6. s2(); // b--;
  7. s2(); // 让出 8
  8. s1(); // a++;
  9. s2(); // a = 8 + b;
  10. // 让出 2
  11. s1(); // b = b * a;
  12. // 让出 b
  13. s1(); // a = b + 3;
  14. s2(); // b = a * 2;

在我告诉你结果之前,你能指出在前面的程序运行之后ab的值是什么吗?不要作弊!

  1. console.log( a, b ); // 12 18

注意: 作为留给读者的练习,试试通过重新安排s1()s2()调用的顺序,看看你能得到多少种结果组合。别忘了你总是需要三个s1()调用和四个s2()调用。至于为什么,回想一下刚才关于使用yield匹配next()的讨论。

当然,你几乎不会想有意制造 这种 水平的,令人糊涂的穿插,因为他创建了非常难理解的代码。但是这个练习很有趣,而且对于理解多个generator如何并发地运行在相同的共享作用域来说很有教育意义,因为会有一些地方这种能力十分有用。

我们会在本章末尾更详细地讨论generator并发。