生成器(Generator)可以說是在 ES2015 中最為強悍的一個新特性,因為生成器是涉及到 ECMAScript 引擎運行底層的特性,生成器可以實現一些從前無法想象的事情。
來龍
生成器第一次出現在 CLU1 語言中,這門語言是由 MIT (美國麻省理工大學)的 Barbara Liskov 教授和她的學生們在 1974 年至 1975 年所設計和開發出來的。這門語言雖然古老,但是卻提出了很多如今被廣泛使用的編程語言特性,而生成器便是其中的一個。
而在 CLU 語言之后,有 Icon 語言2、Python 語言3、C# 語言4和 Ruby 語言5等都受 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
又是什么語句?
不必著急,我們一一道來。
基本概念
生成器是 ES2015 中同時包含語法和底層支持的一個新特性,其中有幾個相關的概念是需要先了解的。
生成器函數(Generator Function)
生成器函數是 ES2015 中生成器的最主要表現方式,它與普通的函數語法差別在于,在 function
語句之后和函數名之前,有一個 *
作為它是一個生成器函數的標示符。
function* fibo() {
// ...
}
生成器函數的定義并不是強制性使用聲明式的,與普通函數一樣可以使用定義式進行定義。
const fnName = function*() { /* ... */ }
生成器函數的函數體內容將會是所生成的生成器的執行內容,在這些內容之中,yield
語句的引入使得生成器函數與普通函數有了區別。yield
語句的作用與 return
語句有些相似,但 yield
語句的作用并非退出函數體,而是切出當前函數的運行時(此處為一個類協程,Semi-coroutine),并與此同時可以講一個值(可以是任何類型)帶到主線程中。
我們以一個比較形象的例子來做比喻,你可以把整個生成器運行時看成一條長長的瑞士卷(while (true)
則就是無限長的),ECMAScript 引擎在每一次遇到 yield
就要切一刀,而切面所成的“紋路”則是 yield
出來的值。
生成器(Generator)
從計算機科學角度上看,生成器是一種類協程或半協程(Semi-coroutine),生成器提供了一種可以通過特定語句或方法來使生成器的執行對象(Execution)暫停,而這語句一般都是 yield
。上面的斐波那契數列的生成器便是通過 yield
語句將每一次的公式計算結果切出執行對象,并帶到主線程上來。
在 ES2015 中,yield
可以將一個值帶出協程,而主線程也可以通過生成器對象的方法將一個值帶回生成器的執行對象中去。
const inputValue = yield outputValue
生成器切出執行對象并帶出 outputValue
,主線程經過同步或異步的處理后,通過 .next(val)
方法將 inputValue
帶回生成器的執行對象中。
使用方法
在了解了生成器的背景知識后,我們就可以開始來看看在 ES2015 中,我們要如何使用這個新特性。
構建生成器函數
使用生成器的第一步自然是要構建一個生成器函數,以生成相對應的生成器對象。假設我們需要按照下面這個公式來生成一個數列,并以生成器作為構建基礎。(此處我們暫不作公式化簡)
為了使得生成器能夠不斷根據公式輸出數列元素,我們與上面的斐波那契數列實例一樣,使用 while (true)
循環以保持程序的不斷執行。
function* genFn() {
let a = 2
yield a
while (true) {
yield a = a / (2 * a + 1)
}
}
在定義首項為 2 之后,首先將首項通過 yield
作為第一個值切出,其后通過循環和公式將每一項輸出。
啟動生成器
生成器函數不能直接作為函數來使用,執行生成器函數會返回一個生成器對象,將用于運行生成器內容和接受其中的值。
const gen = genFn()
生成器是是通過生成器函數的一個生成器(類)實例,我們可以簡單地用一段偽代碼來說明生成器這個類的基本內容和用法。
class Generator {
next(value)
throw(error)
[@iterator]()
}
操作方法(語法) | 方法內容 |
---|---|
generator.next(value) |
獲取下一個生成器切出狀態。(第一次執行時為第一個切出狀態)。 |
generator.throw(error) |
向當前生成器執行對象拋出一個錯誤,并終止生成器的運行。 |
generator[@iterator] |
@iterator 即 Symbol.iterator ,為生成器提供實現可迭代對象的方法。使其可以直接被 for...of 循環語句直接使用。 |
其中 .next(value)
方法會返回一個狀態對象,其中包含當前生成器的運行狀態和所返回的值。
{
value: Any,
done: Boolean
}
生成器執行對象會不斷檢查生成器的狀態,一旦遇到生成器內的最后一個 yield
語句或第一個 return
語句時,生成器便進入終止狀態,即狀態對象中的 done
屬性會從 false
變為 true
。
而 .throw(error)
方法會提前讓生成器進入終止狀態,并將 error
作為錯誤拋出。
運行生成器內容
因為生成器對象自身也是一種可迭代對象,所以我們直接使用 for...of
循環將其中輸出的值打印出來。
for (const a of gen) {
if (a < 1/100) break
console.log(a)
}
//=>
// 2
// 0.4
// 0.2222222222
// ...
深入理解
運行模式
為了能更好地理解生成器內部的運行模式,我們將上面的這個例子以流程圖的形式展示出來。
生成器是一種可以被暫停的運行時,在這個例子中,每一次 yield
都會將當前生成器執行對象暫停并輸出一個值到主線程。而這在生成器內部的代碼是不需要做過多體現的,只需要清楚 yield
語句是暫停的標志及其作用即可。
生成器函數以及生成器對象的檢測
事實上 ES2015 的生成器函數也是一種構造函數或類,開發者定義的每一個生成器函數都可以看做對應生成器的類,而所產生的生成器都是這些類的派生實例。
在很多基于類(或原型)的庫中,我們可以經常看到這樣的代碼。
function Point(x, y) {
if (!(this instanceof Point)) return new Point(x, y)
// ...
}
const p1 = new Point(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() //=> /(?:)/
TIPS: 在代碼風格檢查工具 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
來檢驗一個函數是否是生成器函數。
function isGeneratorFunction(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
顯然出于性能考慮,我們可以將這個判定函數利用惰性加載進行優化。
function isGeneratorFunction(fn) {
const genFn = (function*(){}).constructor
return (isGeneratorFunction = fn => fn instanceof genFn)(fn)
}
相對于生成器函數,生成器實例的檢測就更為困難。因為無法通過對已知生成器實例自身的屬性來獲取被運行引擎所隱藏起來的 Generator
構造函數,所以無法直接用 instanceof
語句來進行類型檢測。也就是說我們需要利用別的方法來實現這個需求。
在上一個章節中,我們介紹到了在 ECMAScript 中,每一個對象都會有一個 toString()
方法的實現以及其中一部分有 Symbol.toStringTag
作為屬性鍵的屬性,以用于輸出一個為了填補引用對象無法被直接序列化的字符串。而這個字符串是可以間接地探測出這個對象的構造函數名稱,即帶有直接關系的類。
那么對于生成器對象來說,與它擁有直接關系的類除了其對應的生成器函數以外,便是被隱藏起來的 Generator
類了。而生成器對象的 @@toStringTag
屬性正正也是 Generator
,這樣的話我們就有了實現的思路了。在著名的 JavaScript 工具類庫 LoDash6 的類型檢測中,正式使用了(包括但不限于)這種方法來對未知對象進行類型檢查,而我們也可以試著使用這種手段。
function isGenerator(obj) {
return obj.toString ? obj.toString() === '[object Generator]' : false
}
function* genFn() {}
const gen = genFn()
console.log(isGenerator(gen)) //=> true
console.log(isGenerator({})) //=> false
而另外一方面,我們既然已經知道了生成器實例必定帶有 @@toStringTag
屬性并其值夜必定為 Generator
,我們也可以通過這個來檢測位置對象是否為生成器實例。
function isGenerator(obj) {
return obj[Symbol && Symbol.toStringTag ? Symbol.toStringTag : false] === 'Generator'
}
console.log(isGenerator(gen)) //=> true
console.log(isGenerator({})) //=> false
此處為了防止因為運行環境不支持 Symbol
或 @@toStringTag
而導致報錯,需要使用先做兼容性檢測以完成兼容降級。
而我們再回過頭來看看生成器函數,我們是否也可以使用 @@toStringTag
屬性來對生成器函數進行類型檢測呢?我們在一個同時支持生成器和 @@toStringTag
的運行環境中運行下面這段代碼。
function* genFn() {}
console.log(genFn[Symbol.toStringTag]) //=> GeneratorFunction
這顯然是可行的,那么我們就來為前面的 isGeneratorFunction
方法再進行優化。
function isGeneratorFunction(fn) {
return fn[Symbol && Symbol.toStringTag ? Symbol.toStringTag : false] === 'GeneratorFunction'
}
console.log(isGeneratorFunction(genFn)) //=> true
而當運行環境不支持 @@toStringTag
時也可以通過 instanceof
語句來進行檢測。
function isGeneratorFunction(fn) {
// If the current engine supports Symbol and @@toStringTag
if (Symbol && Symbol.toStringTag) {
return (isGeneratorFunction = fn => fn[Symbol.toStringTag] === 'GeneratorFunction')(fn)
}
// Using instanceof statement for detecting
const genFn = (function*(){}).constructor
return (isGeneratorFunction = fn => fn instanceof genFn)(fn)
}
console.log(isGeneratorFunction(genFn)) //=> true
生成器嵌套
雖然說到現在為止,我們所舉出的生成器例子都是單一生成器進行使用。但是在實際開發中,我們同樣會遇到需要一個生成器嵌套在另一個生成器內的情況,就比如數學中的分段函數或嵌套的數組公式等。
我們假設有這樣的一個分段函數,我們需要對其進行積分計算。
分別對分段函數的各分段作積分,以便編寫程序進行積分。
此處我們可以分別對分段函數的兩個部分分別建立生成器函數并使用牛頓-科特斯公式(Newton-Cotes formulas)7來進行積分計算。
// 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() {
yield 1
yield 2
}
function* bar() {
yield* foo()
yield 3
yield 4
}
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()
生成器的所有輸出值相加即可。
生成器 ≈ 協程?
從運行機制的角度上看,生成器擁有暫停運行時的能力,那么生成器的運用是否只僅限于生成數據呢?在上文中,我們提到了生成器是一種類協程,而協程自身是可以通過生成器的特性來進行模擬呢。
在現代 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
function echo(content, callback) {
callback(null, content)
}
// After
function echo(content) {
return callback => {
callback(null, content)
}
}
這樣我們就可以在生成器內使用這個異步方法了。但是還不足夠,將方法帶出生成器執行對象后,還需要在主線程將帶出的函數執行才可實現應有的需求。上面我們通過封裝所得到的異步方法在生成器內部執行后,可以通過 yield
語句將內層的函數帶到主線程中。這樣我們就可以在主線程中執行這個函數并得到返回值,然后將其返回到生成器執行對象中。
function run(genFn) {
const gen = genFn()
const next = value => {
const ret = gen.next(value)
if (ret.done) return
ret.value((err, val) => {
if (err) return console.error(err)
// Looop
next(val)
})
}
// First call
next()
}
通過這個運行工具,我們便可以將生成器函數作為邏輯的運行載體,從而將之前多層嵌套的異步操作全部扁平化。
run(function*() {
const msg1 = yield echo('Hello')
const msg2 = yield echo(`${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 = new Buffer('Hello World')
co(function*() {
const exists = yield promisify(fs.exists(filepath))
if (exists) {
const data = yield promisify(fs.readFile(filepath))
// ...
} else {
yield promisify(fs.writeFile(filepath, defaultData))
// ...
}
})
[1] CLU Language http://www.pmg.lcs.mit.edu/CLU.html
[2] Icon Language http://www.cs.arizona.edu/icon
[3] Python Language http://www.python.org
[4] C# Language http://msdn.microsoft.com/pt-br/vcsharp/default.aspx
[5] Ruby Language http://www.ruby-lang.org
[6] LoDash https://lodash.com
[7] Newton-Cotes formulas https://en.wikipedia.org/wiki/Newton%E2%80%93Cotes_formulas