生成器(Generator)

生成器(Generator)能夠說是在 ES2015 中最爲強悍的一個新特性,由於生成器是涉及到 ECMAScript 引擎運行底層的特性,生成器能夠實現一些從前沒法想象的事情。javascript

來龍

生成器第一次出如今 CLU1 語言中,這門語言是由 MIT (美國麻省理工大學)的 Barbara Liskov 教授和她的學生們在 1974 年至 1975 年所設計和開發出來的。這門語言雖然古老,可是卻提出了不少現在被普遍使用的編程語言特性,而生成器即是其中的一個。html

而在 CLU 語言以後,有 Icon 語言2、Python 語言3、C# 語言4和 Ruby 語言5等都受 CLU 語言影響,實現了生成器的特性。在 CLU 語言和 C# 語言中,生成器被稱爲迭代器(Iterator),而在 Ruby 語言中稱爲枚舉器(Enumerator)。java

然而不管它被成爲何,所被賦予的能力都是相同的。生成器的主要目的是用於經過一段程序,來持續被迭代或枚舉出符合某個公式或算法的有序數列中的元素,而這個程序即是用於實現這個公式或算法,而不須要將目標數列完整寫出。python

咱們來舉一個簡單的例子,斐波那契數列是很是著名一個理論數學基礎數列。它的前兩項是 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 又是什麼語句?k4ruby

沒必要着急,咱們一一道來。異步

基本概念

生成器是 ES2015 中同時包含語法和底層支持的一個新特性,其中有幾個相關的概念是須要先了解的。編程語言

生成器函數(Generator Function)

生成器函數是 ES2015 中生成器的最主要表現方式,它與普通的函數語法差異在於,在 function 語句以後和函數名以前,有一個 * 做爲它是一個生成器函數的標示符。

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

生成器函數的定義並非強制性使用聲明式的,與普通函數同樣可使用定義式進行定義。

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

生成器函數的函數體內容將會是所生成的生成器的執行內容,在這些內容之中,yield 語句的引入使得生成器函數與普通函數有了區別。yield 語句的做用與 return 語句有些類似,但 yield 語句的做用並不是退出函數體,而是切出當前函數的運行時(此處爲一個類協程,Semi-coroutine),並與此同時能夠講一個值(能夠是任何類型)帶到主線程中。

咱們以一個比較形象的例子來作比喻,你能夠把整個生成器運行時當作一條長長的瑞士捲(while (true) 則就是無限長的),ECMAScript 引擎在每一次遇到 yield 就要切一刀,而切面所成的「紋路」則是 yield 出來的值。

Swiss Roll

生成器(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] @iteratorSymbol.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
//  ...

深刻理解

運行模式

爲了能更好地理解生成器內部的運行模式,咱們將上面的這個例子以流程圖的形式展現出來。

圖解 Generator

生成器是一種能夠被暫停的運行時,在這個例子中,每一次 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 內部中的絕大部分類型構造函數(不包括 MapSet 及他們的 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 V八、Mozilla SpiderMonkey)並無將 GeneratorFunctionGenerator 類暴露出來。這就意味着沒辦法簡單地使用 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))
    // ...
  }
})

Reference

[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

相關文章
相關標籤/搜索