基於 Generator 和 Iterator 的惰性列表

初識 Lazy List

若是有了解過 Haskell 的朋友,對下面的這些表達必定不陌生javascript

repeat 1 -- => [1, 1, 1, 1, 1,...]
cycle "abc" -- => "abcabcabc..."
[1, 3..] -- => [1, 3, 5, 7, ...]
複製代碼

上面的幾個表達式產生的都是無限列表。對於習慣了主流編程語音的朋友可能感到困惑,在有限的內存裏面如何能表達無限的概念。主要的緣由就是 Haskell 是一門默認採用惰性求值策略的語言,沒有用到的部分,在內存裏面只是一個表達式,並不會真正的去作計算。前端

若是只看上面的幾個表達式,不少朋友可能會說,也沒感受到有什麼神奇的地方,彷佛並無什麼做用。咱們再看看下面的代碼。java

Haskell 中的 fibonacci 數列:node

fibonacci = 1 : 1 : zipWith (+) fibonacci (tail fibonacci)
複製代碼

這裏 fibonacci 自己是一個惰性結構,因此在計算的時候,會先算出列表前面的兩個1,獲得 1 : 1... 這樣的結構,而後怎麼表達 fibonaccifib(n) = fib(n - 1) + fib(n - 2) 特性呢?咱們能夠注意到,n - 1n - 2 恰好在數列中相差一位,因此 n 能夠看做是該數列錯位的相加的結果。git

咱們再來看一則篩法求素數。不熟悉篩法的能夠先點開 wiki 去看一下該算法的思路。下面這段代碼是 Haskell 的一個簡單實現。es6

primes = 2 : filter isPrime [3, 5..]
  where
    isPrime x = all (\p -> x `mod` p > 0) (takeWhile (\p -> p * p <= x) primes)
複製代碼

So, Why Lazy?

在某些不定長度的列表操做上,惰性列表會讓代碼和結構更靈活。用上面的 primes 列表舉個例子好了,在傳統的 C 語言或者 Java 的實現裏面,咱們通常要先聲明一個最大長度或者一個最大的取值範圍,好比 10000 之內的素數。若是後面的計算要用到超過這個範圍,咱們就不得不從新調用生成函數,從新生成一份更長的列表。這裏面的問題是:1、要主動去調用這個工廠函數,2、若是要複用已經計算出來的數據,手動去維護一個cache列表,勢必增長代碼的複雜度。另一個可能的狀況是,咱們預先生成了一份很長的列表,後面的計算中只用到了列表頭部的一丟丟數據,這也是極大的浪費。github

惰性列表的使用增長了咱們編程的表達能力,讓咱們能夠更關注數據結構自己的特性,而不是浪費時間在如何去管理堆棧上面。由於,惰性求值特性保證咱們在須要一個值的時候才會去計算,因此能夠自動地最小化咱們的計算量,節約資源。算法

好比咱們能夠經過 lazy byteString 去讀、寫文件,它自己不會把整個文件加載到咱們的內存裏面,而是按需的讀取。有的時候咱們讀一個大文件,可能只篩選出須要的前幾十條數據,卻確不得不把幾百 M 甚至上 G 的大文件整個的放到內存裏面。編程

這裏也找到一篇14年的文章 How to Speed Up Lo-Dash ×100? Introducing Lazy Evaluation,感興趣的能夠點開看看。數組

在 JavaScript 中實現 Lazy List

在 JavaScript 有沒有惰性結構呢?先看下面這個例子。

let fetchSomething = fetch('/some/thing');
if (condition) {
  fetchSomething = fetch('/some/thing/condition');
}
fetchSomething.then(() => {
  // TODO
});
複製代碼

fetch 方法自己是當即執行的,若是知足條件,這裏的 fetch 方法會執行兩次。這並非咱們期待的行爲,這裏須要讓這個 fetch 的動做在咱們須要的時候纔去執行,而不是聲明的時候就開始執行的話,一般的作法是把它改爲下面的樣子。

let fetchSomething = () => fetch('/some/thing');
if (condition) {
  fetchSomething = () = fetch('/some/thing/condition');
}
fetchSomething.then(() => {
  // TODO
});
複製代碼

由此啓發,咱們大體能夠實現以下的結構。

class List<T> {
  head: T | () => T
  tail: List<T> | () => List<T>

  constructor(head: T, tail: () => List<T>) {
    this.head = () => head;
    this.tail = tail;
  }
}
複製代碼

List<T> 本質上是一個單鏈表,構造函數裏面傳入的 tail 是一個工廠函數,用來構建新的 List 節點。只有在咱們訪問到一個節點的時候,纔對它的 head 求值,訪問它的下一個節點的時候對 tail 求值,否則 head 和 tail 都只是待求值的表達式。

這種方式看起來彷佛已經解決了個人問題,可是這種結構在和普通的 Array 作互相轉換的時候,存在大量沒必要要的額外開銷。

那 JavaScript 中有沒有更自然的結構,可讓咱們免於去構造這樣一個複雜的對象,簡化代碼的同時,讓咱們的代碼更具備普適性呢?

初識 Iterable

ES6 的新特性給了我想要的答案,Iteration Protocols。若是嫌MDN的描述太長,能夠直接看下面等價的類型聲明。

interface Iterable<T> {
  [Symbol.iterator](): Iterator<T>;
}

interface Iterator<T> {
  next(): IteratorResult<T>;
}

interface IteratorResult<T> {
  done: Boolean;
  value?: T;
}

interface IterableIterator<T> {
  [Symbol.iterator](): Iterator<T>;
  next(): IteratorResult<T>;
}
複製代碼

全部實現一個Iterable接口的對象均可以經過諸如 for...of...、...itor 以及 Array.from 來訪問,當next方法的返回值中done爲true時,迭代結束。並且只有咱們訪問next方法時,纔會進入下一步迭代,是理想的Lazy結構。

這時候咱們看一下咱們的 fibonacci 該怎麼寫?

class Fibonacci implements IterableIterator<number> {
  private prev = 1;
  private next = 1;

  public next() {
    let current = this.prev;
    this.prev = this.next;
    this.next = current + this.prev;
    return {
      done: false,
      value: current
    }
  }

  [Symbol.iterator]() {
    return this;
  }
}

const fib = new Fibonacci();
fib.next() // => { done: false, value: 1 }
fib.next() // => { done: false, value: 1 }
fib.next() // => { done: false, value: 2 }
// etc
複製代碼

到這裏,咱們已經能夠表達一個惰性的無限數列了。可是上面的代碼畢竟過於繁瑣,好在 ES6 同時給咱們提供了 Generator, 可讓咱們很方便地書寫 IterableItorator,從某種意義上來說,Generator 能夠說是上面代碼的語法糖。

使用Generator,上面的代碼能夠簡化成下面的樣子。

export function* fibonacci() {
  let prev = 1;
  let next = 1;

  while (true) {
    yield prev;
    const temp = prev;
    prev = next;
    next = temp + prev;
  }
}

const fib = fibonacci();
// etc
複製代碼

這裏再也不去花段落介紹 Generator 的語法,不瞭解的同窗能夠先去閱讀下這篇文章 A Simple Guide to Understanding Javascript (ES6) Generators

定義 Infinite List

接着上面的代碼往下寫,下面的代碼分別實現了文章開頭的 repeat, cycle, iterate, range 等方法。

export function* repeat<T>(item: T) {
  while (true) {
    yield item;
  }
}

export function* cycle<T>(items: Iterable<T>) {
  while (true) {
    yield* [...items];
  }
}

export function* iterate<T>(fn: (value: T) => T, initial: T) {
  let val = initial;
  while (true) {
    yield val;
    val = fn(val);
  }
}

export function* range(start: number, end = Infinity, step = 1) {
  while (start <= end) {
    yield start;
    start += step;
  }
}
複製代碼

能夠看到,代碼是很是直觀且易於理解的。

定義 Operator

有了列表以後,咱們須要在列表之上進行操做,下面的代碼分別實現了 map/filter/take/takeWhile 方法。

export function* map<T, U>(fn: (value: T) => U, items: Iterable<T>) {
  for (let item of items) {
    yield fn(item);
  }
}

export function* filter<T>(
  predicate: (value: T) => boolean,
  items: Iterable<T>
) {
  for (let item of items) {
    if (predicate(item)) {
      yield item;
    }
  }
}

export function* take<T>(n: number, items: Iterable<T>) {
  let i = 0;
  if (n < 1) return;

  for (let item of items) {
    yield item;
    i++;
    if (i >= n) {
      return;
    }
  }
}

function* takeWhile<T>(
  predicate: (value: T) => boolean,
  items: Iterable<T>
) {
  for (let item of items) {
    if (predicate(item)) {
      yield item;
    } else {
      return;
    }
  }
}
複製代碼

上面的代碼都是比較簡單的。比較難一點的是去實現 zip 方法,即怎麼把兩個列表合併成一個?

難點在於接收一個 Iterable 的對象的話,自己並不必定要實現 next 方法的,好比 Array、String 等,同時Iterable對象也並非均可以經過 index 來訪問的。此外,若是想先經過Array.from變成數組,而後在數組上進行操做,咱們會遇到一個狀況是咱們傳入的 Iterable 對象是無限的,如上文的 fibonacci 同樣,這種狀況下是不能使用 Array.from 的。

這時候個人一個思路是須要想辦法把一個 Iterable 的對象提高成爲 IterableItorator 對象,而後經過 next 方法,逐一遍歷。

How ?幸虧 Generator 給咱們提供了一個 yield* 操做符,可讓咱們方便的定義出一個 lift 方法。

export function* lift<T>(items: Iterable<T>): IterableIterator<T> {
  yield* items;
}
複製代碼

有了這個 lift 方法以後,就能夠很方便的書寫 zip 方法和 zipWith 方法了。

export function* zip<T, G>(
  seqA: Iterable<T>,
  seqB: Iterable<G>
): IterableIterator<[T, G]> {
  const itorA = lift(seqA);
  const itorB = lift(seqB);
  let valA = itorA.next();
  let valB = itorB.next();
  while (!valA.done || !valB.done) {
    yield [valA.value, valB.value];
    valA = itorA.next();
    valB = itorB.next();
  }
}

export function* zipWith<T, G, R>(
  fn: (a: T, b: G) => R,
  seqA: Iterable<T>,
  seqB: Iterable<G>
): IterableIterator<R> {
  const itorA = lift(seqA);
  const itorB = lift(seqB);
  let valA = itorA.next();
  let valB = itorB.next();
  while (!valA.done || !valB.done) {
    yield fn(valA.value, valB.value);
    valA = itorA.next();
    valB = itorB.next();
  }
}
複製代碼

更多的方法能夠去底部的點開個人 repo,這裏就不一一列舉了。

結語

Generator 和 Iterator 是 ES6 帶給咱們的很是強大的語言層面的能力,它自己的求值能夠看做是惰性的。

差很少在13年左右,TJ 的 co 剛出來的時候,其代碼的短小精悍能夠說是至關驚豔的。然而在咱們的使用中,一來受限於瀏覽器兼容性,二來受限於咱們的使用場景,我的認爲咱們對其特性開發得還遠遠不夠。結合 IO、network,Generator 和 Iterator 還能爲咱們作更多的事情。

另外,須要特別說明的是,雖然這篇文章通篇是在講惰性列表,可是惰性列表並非銀彈,相反的,惰性結構的濫用會在程序的執行過程當中緩存大量的thunk,增大在內存上的開銷。

最後,利益相關 - 有贊招前端,簡歷請投 wangqiao@youzan.com

本文首發於有贊技術博客

相關文章
相關標籤/搜索