返回 登录
0

ES2015新语法详解——生成器(Generator)

引言:生成器(Generator)可以说是在ES2015中最为强悍的一个新特性,因为生成器是涉及ECMAScript引擎运行底层的特性,可以实现一些从前无法想象的事情。
本文选自《实战ES2015:深入现代JavaScript 应用开发》。

1由来

生成器第一次出现在CLU语言中CLU语言是由美国麻省理工大学的Barbara Liskov教授和她的学生们在1974年至1975年间所设计和开发出来的,这门语言虽然古老,但是却提出了很多如今被广泛使用的编程语言特性,生成器便是其中的一个。
在CLU语言之后出现的Icon语言、Python语言、C#语言和Ruby语言等,都受到CLU语言的影响,实现了生成器的特性。生成器在CLU语言和C#语言中被称为迭代器(Iterator),而在Ruby语言中被称为枚举器(Enumerator)。
然而无论被称为什么,它被赋予的能力都是相同的。生成器的主要功能是:通过一段程序,持续迭代或枚举出符合某个公式或算法的有序数列中的元素。这个程序便是用于实现这个公式或算法的,而不需要将目标数列完整写出。
我们来举一个简单的例子,斐波那契数列是非常著名一个理论数学基础数列,它的前两项是0和1,从第三项开始所有的元素都遵循如下公式。
图片描述
那么,根据公式我们编写程序并实现如下。

const fibonacci = [ 0,1 ]
const n =10

for (let i =2; i < n -1;++i) {
fibonacci.push(fibonacci[i -1] + fibonacci[i -2])
}
console.log(fibonacci) //=> [0, 1, 1, 2, 3, 5, 8, 13, 21]

但是这种情况下只能确定一个数量来取得相应的数列,若要按需获取元素,那就可以利用生成器来实现了。

function*fibo() {
let a =0
let b =1

yield a
yield b

while (true) {
let next = a + b
    a = b
    b = next
yield next
}
}

let generator =fibo()

for (var i =0; i <10; i++)
console.log(generator.next().value) //=> 0 1 1 2 3 5 8 13 21 34 55

你一定会对这段代码感到很奇怪,为什么function语句后会有一个“*”?为什么函数里使用了while (true)却没有因为进入死循环而导致程序卡死?而这个yield又是什么语句?
不必着急,我们一一道来。

2基本概念

生成器是ES2015中同时包含语法和底层支持的一个新特性,其中有几个相关概念需要事先了解。

2.1 生成器函数(Generator Function)

生成器函数是ES2015中生成器的最主要表现方式,它与普通函数的语法差别在于,在function语句之后和函数名之前,有一个“*”作为生成器函数的标示符。

function*fibo() {
// ...
}

生成器函数并不是强制性使用声明式进行定义的,与普通函数一样也可以使用表达式进行定义。

const fnName =function*() {/* ... */}

生成器函数的函数体内容将会是所生成的生成器的执行内容,在这些内容之中,yield语句的引入使得生成器函数与普通函数有了区别。yield语句的作用与return语句有些相似,但并非退出函数体,而是切出当前函数的运行时(此处为一个类协程,Semi-coroutine),与此同时可以将一个值(可以是任何类型)带到主线程中。
我们以一个比较形象的例子来做比喻,你可以把整个生成器运行时看成一条长长的瑞士卷,while (true)是无限长的,ECMAScript引擎每一次遇到yield语句时,就好比在瑞士卷上切一刀,而切面所呈现的“纹路”则是yield语句所得的值。
图片描述

2.2 生成器(Generator)

从计算机科学角度上看,生成器是一种类协程或半协程(Semi-coroutine),它提供了一种可以通过特定语句或方法使其执行对象(Execution)暂停的功能,而这语句一般都是yield语句。上面的斐波那契数列生成器便是通过yield语句将每一次的公式计算结果切出执行对象,并带到主线程上来的。
在ES2015中,yield语句可以将一个值带出协程,而主线程也可以通过生成器对象的方法将一个值带回生成器的执行对象中去。

const inputValue =yield outputValue

生成器切出执行对象并带出outputValue,主线程经过同步或异步处理后,通过.next(val)方法将inputValue带回生成器的执行对象中。

3使用方法

在了解了生成器的背景知识后,我们就可以看看在ES2015中要如何使用这个新特性。

3.1 构建生成器函数

使用生成器的第一步自然是要构建一个生成器函数,以生成相对应的生成器对象。假设我们需要按照下面这个公式(此处我们暂不作公式化简)来生成一个数列,并以生成器作为构建基础。
图片描述
为了使生成器能够根据公式不断输出数列元素,我们与上面的斐波那契数列实例一样,使用while (true)循环以保持程序的不断执行。

function*genFn() {
let a =2

yield a

while (true) {
yield a = a / (2* a +1)
}
}

在定义首项为2之后,首先将首项通过yield作为第一个值切出,其后通过循环和公式将每一项输出。

3.2启动生成器

生成器函数不能直接作为普通的函数来使用,因为在调用时无法直接执行其中的逻辑代码。执行生成器函数会返回一个生成器对象,用于运行生成器内容和接受其中的值。

const gen =genFn()

生成器是通过生成器函数实现的一个生成器(类)实例,我们可以简单地用一段伪代码来说明生成器这个类的基本内容和用法,具体对应关系如下表。

class Generator {
next(value)
throw(error)
  [@@iterator]()
}

图片描述
其中.next(value)方法会返回一个状态对象,包含当前生成器的运行状态和所返回的值。

{
value: Any,
done: Boolean
}

生成器执行对象会不断检查生成器的状态,一旦遇到生成器内的最后一个yield语句或第一个return语句便进入终止状态,即状态对象中的done属性会从false变为true。
而.throw(error)方法会提前让生成器进入终止状态,并将error作为错误抛出。

3.3 运行生成器内容

因为生成器对象自身也是一种可迭代对象,所以我们直接使用for-of循环将其中输出的值打印出来。

for (const a of gen) {
if (a <1/100) break

console.log(a)
}
//=>
//  2
//  0.4
//  0.2222222222
//  ...

4深入理解

4.1运行模式

为了能更好地理解生成器内部的运行模式,我们将上面的例子以流程图的形式展示出来。
图片描述
生成器是一种可以被暂停的运行时,在这个例子中,每一次执行yield都会将当前生成器执行对象暂停并输出一个值到主线程。这在生成器内部的代码上是不需要过多体现的,只需要清楚yield语句是暂停的标志及其作用即可。

4.2 生成器函数以及生成器对象的检测

事实上ES2015的生成器函数也是一种构造函数或类,开发者定义的每一个生成器函数都可以看做对应生成器的类,而所产生的生成器都是这些类的派生实例。
在很多基于类(或原型)的库中,我们经常可以看到如下的代码。

functionPoint(x, y) {
if (!(thisinstanceof Point)) returnnewPoint(x, y)
// ...
}

const p1 =newPoint(1,2)
const p2 =Point(2,3)

这一句代码的作用是为了避免开发者在创建某一个类的实例时没有使用new语句而出现错误。ECMAScript内部中的绝大部分类型构造函数(不包括Map和Set及他们的Weak版本)都带有这种特性。

String()  //=> ""
Number()  //=> 0
Boolean() //=> false
Object()  //=> Object {}
Array()   //=> []
Date()    //=> the current time
RegExp()  //=> /(?:)/

在代码风格检查工具ESLint中有一个名为no-new的可选特性,即相比使用new,更倾向于使用直接调用构造函数来创建实例。
那么同样地,生成器函数也支持这种特性,互联网上的大多数文献都使用了直接执行的方法创建生成器实例。如果我们尝试嗅探生成器函数和生成器实例的原型,可以得到如下信息。

function*genFn() {}
const gen =genFn()

console.log(genFn.constructor.prototype) //=> GeneratorFunction
console.log(gen.constructor.prototype)   //=> Generator

这样我们便可知,我们可以通过使用instanceof语句来得知一个生成器实例是否为一个生成器函数所对应的实例。

console.log(gen instanceof genFn) //=> true

十分可惜的是,目前原生支持生成器的主流JavaScript引擎(如Google V8、Mozilla SpiderMonkey)并没有将GeneratorFunction和Generator类暴露出来。这就意味着没办法简单使用instanceof来判定一个对象是否是生成器函数或生成器实例。如果你确实希望检测一个未知的对象是否为一个生成器函数或者生成器实例,也可以通过一些取巧的办法。
对于原生支持生成器的运行环境来说,生成器函数自身带有一个constructor属性指向并没有被暴露出来的GeneratorFunction。那么我们就可以利用一个我们已知的生成器函数的constructor来检验一个函数是否为生成器函数。

functionisGeneratorFunction(fn) {
const genFn = (function*(){}).constructor

return fn instanceof genFn
}

function*genFn() {
let a =2

yield a

while (true) {
yield a = a / (2* a +1)
}
}

console.log(isGeneratorFunction(genFn)) //=> true

相对于生成器函数,生成器实例的检测更为困难。因为无法通过已知生成器实例自身的属性来获取被运行引擎所隐藏起来的Generator构造函数,所以无法直接用instanceof语句来进行类型检测,也就是说我们需要利用别的方法来实现这个需求。
在前面的章节中,我们介绍到,在ECMAScript中每一个对象都会有一个toString()方法的实现以及其中一部分有Symbol.toStringTag作为属性键的属性,用于输出一个为了填补引用对象无法被直接序列化的字符串。而这个字符串是可以间接地探测出这个对象的构造函数名称的,即带有直接关系的类。
那么对于生成器对象来说,与它有直接关系的类除了其对应的生成器函数以外,便是被隐藏起来的Generator类了。而生成器对象的@@toStringTag属性正正也是Generator,这样的话我们就有了实现的思路。在著名的JavaScript工具类库LoDash的类型检测中,正式使用了这种方法(包括但不限于)来对未知对象进行类型检查,我们也可以试着使用这种手段。

functionisGenerator(obj) {
returnobj.toString?obj.toString() ==='[object Generator]' : false
}

function*genFn() {}
const gen =genFn()

console.log(isGenerator(gen)) //=> true
console.log(isGenerator({}))  //=> false

另一方面,我们既然已经知道了生成器实例必定带有@@toStringTag属性,其值也必定为Generator,那么便可以通过这个来检测位置对象是否为生成器实例。

functionisGenerator(obj) {
if (Symbol &&Symbol.toStringTag) {
return obj[Symbol.toStringTag] ==='Generator'
}elseif (obj.toString) {
returnobj.toString() ==='[object Generator]'
}

returnfalse
}

console.log(isGenerator(gen)) //=> true
console.log(isGenerator({}))  //=> false

此处为了防止因为运行环境不支持Symbol或@@toStringTag而产生报错,需要先做兼容性检测以完成兼容降级。
我们回过头来再看看生成器函数,是否也可以使用@@toStringTag属性来对生成器函数进行类型检测呢?我们在一个同时支持生成器和@@toStringTag的运行环境中运行下面这段代码。

function*genFn() {}

console.log(genFn[Symbol.toStringTag]) //=> GeneratorFunction

这显然是可行的,那么我们就来将前面的isGeneratorFunction方法进行优化。

functionisGeneratorFunction(fn) {
return fn[Symbol &&Symbol.toStringTag?Symbol.toStringTag : false] ==='GeneratorFunction'
}

console.log(isGeneratorFunction(genFn)) //=> true

而当运行环境不支持@@toStringTag时也可以通过instanceof语句来进行检测。

functionisGeneratorFunction(fn) {
// If the current engine supports Symbol and @@toStringTag
if (Symbol &&Symbol.toStringTag) {
return fn[Symbol.toStringTag] ==='GeneratorFunction'
}

// Using instanceof statement for detecting
const genFn = (function*(){}).constructor

return fn instanceof genFn
}

console.log(isGeneratorFunction(genFn)) //=> true

4.3 生成器嵌套

虽然说到现在为止,我们所举出的生成器例子都是单一生成器进行使用。但是在实际开发中,我们同样会遇到一个生成器嵌套在另一个生成器内的情况,就比如数学中的分段函数或嵌套的数组公式等。
假设有如下所示的一个分段函数,我们需要对其进行积分计算。
图片描述
分别对分段函数的各段进行积分,以便编写程序实现。
图片描述
此处我们可以在分段函数的两个部分中分别建立生成器函数并使用牛顿-科特斯公式(Newton-Cotes formulas)来进行积分计算。

// Newton-Cotes formulas
function*newton_cotes(f, a, b, n) {
const gaps = (b - a) / n
const h = gaps / 2

for (var i =0; i < n; i++) {
yield h / 45*
      (7*f(a + i * gaps) +
32*f(a + i * gaps +0.25* gaps) +
12*f(a + i * gaps +0.5* gaps) +
32*f(a + i * gaps +0.75* gaps) +
7*f(a + (i +1) * gaps))
}
}

在编写两个分段部分的生成器之前,我们需要先引入一个新语法yield*。它与yield的区别在于,yield*的功能是将一个生成器对象嵌套在另一个生成器内,并将其展开。我们以一个简单地例子进行说明。

function*foo() {
yield1
yield2
}

function*bar() {
yield*foo()
yield3
yield4
}

for (const n of bar()) console.log(n)
//=>
//  1
//  2
//  3
//  4

利用yield*语句我们就可以将生成器进行嵌套和组合,使不同的生成器所输出的值可以被同一个生成器连续输出。

function*Part1(n) {
yield*newton_cotes(x =>Math.pow(x,2),-2,0, n)
}

function*Part2(n) {
yield*newton_cotes(x =>Math.sin(x),0,2, n)
}

function*sum() {
const n =100

yield*Part1(n)
yield*Part2(n)
}

最终我们将sum()生成器的所有输出值相加即可。

4.4生成器与协程

从运行机制的角度上看,生成器拥有暂停运行时的能力,那么生成器的运用是否只限于生成数据呢?在上文中,我们提到生成器是一种类协程,协程自身是可以通过生成器的特性来进行模拟的。
在现代JavaScript应用开发中,我们经常会使用到异步操作(如在Node.js开发中绝大部分使用到的IO操作都是异步的)。但是当异步操作的层级过深时,就可能会出现回调地狱(Callback Hell)的情况。

io1((err, res1) =>{
io2(res1, (err, res2) =>{
io3(res2, (err, res3) =>{
io4(res3, (err, res4) =>{
io5(res5, (err, res5) =>{
// ......
})
})
})
})
})

显然这样很不适合用于真正复杂的开发场景中,那我们究竟要如何进行优化呢?我们知道yield语句可以将一个值带出生成器执行环境,而这个值可以是任何类型的值,这就意味着我们可以利用这一特性做一些有意思的事情。
我们回过头来看看生成器对象的操作方法,生成器执行对象的暂停状态可以用.next(value)方法恢复,这个方法是可以被异步执行的。这就说明如果我们将异步IO的操作通过yield语句来从生成器执行对象带到主线程中,在主线程中完成后再通过.next(value)方法将执行结果带回到生成器执行对象中,这一流程在生成器的代码中是可以以同步的写法完成的。
有了具体思路后,我们先以一个简单的例子来实现。为了实现以生成器作为逻辑执行主体,把异步方法带到主线程去,就要先将异步函数做一层包装,使得其可以在带出生成器执行对象之后再执行。这样我们就可以在生成器内使用这个异步方法了。

// Before
functionecho(content, callback) {
callback(null, content)
}

// After
functionecho(content) {
return callback =>{
callback(null, content)
}
}

但是还不足够,将方法带出生成器执行对象后,还需要在主线程将带出的函数执行才可实现应有的需求。上面我们通过封装所得到的异步方法在生成器内部执行后,可以通过yield语句将内层的函数带到主线程中。这样我们就可以在主线程中执行这个函数并得到返回值,然后将其返回到生成器执行对象中。

functionrun(genFn) {
const gen =genFn()

const next = value =>{
const ret =gen.next(value)
if (ret.done) return

ret.value((err, val) =>{
if (err) returnconsole.error(err)

// Loop
next(val)
})
}

// First call
next()
}

通过这个运行工具,我们便可以将生成器函数作为逻辑的运行载体,从而将之前多层嵌套的异步操作全部扁平化。

run(function*() {
const msg1 =yieldecho('Hello')
const msg2 =yieldecho(`${msg1} World`)

console.log(msg2) //=> Hello Wolrd
})

通过简单的封装,我们已经尝到了一些甜头,那么进一步增强之后又会产生什么有趣的东西呢?Node.js社区中有一个第三方库名为co,意为coroutine,这个库的意义在于利用生成器来模拟协程,而我们这里介绍的就是其中的一部分。co的功能更为丰富,可以直接使用Promise封装工具,如果异步方法有自带的Promise接口,就无需再次封装。此外co还可以直接实现生成器的嵌套调用,也就是说可以通过co来实现逻辑代码的全部同步化开发。

import co from'co'
import{ promisify }from'bluebird'
import fs from'fs'
import path from'path'

const filepath =path.resolve(process.cwd(),'./data.txt')
const defaultData =newBuffer('Hello World')

co(function*() {
const exists =yieldpromisify(fs.exists)(filepath)

if (exists) {
const data =yieldpromisify(fs.readFile)(filepath)
// ...
}else{
yieldpromisify(fs.writeFile)(filepath, defaultData)
// ...
}
})

想及时获得更多精彩文章,可在微信中搜索“博文视点”或者扫描下方二维码并关注。
图片描述

评论