基於 Generator 和 Iterator 的惰性列表
初識 Lazy List
如果有了解過 Haskell 的朋友,對下面的這些表達一定不陌生
repeat 1 -- => [1, 1, 1, 1, 1,...]
cycle "abc" -- => "abcabcabc..."
[1, 3..] -- => [1, 3, 5, 7, ...]
上面的幾個表示式產生的都是無限列表。對於習慣了主流程式語言的朋友可能感到困惑,在有限的記憶體裡面如何能表達無限的概念。主要的原因就是 Haskell 是一門預設採用惰性求值策略的語言,沒有用到的部分,在記憶體裡面只是一個表示式,並不會真正的去做計算。
如果只看上面的幾個表示式,很多朋友可能會說,也沒感覺到有什麼神奇的地方,似乎並沒有什麼作用。我們再看看下面的程式碼。
Haskell 中的 fibonacci
數列:
fibonacci = 1 : 1 : zipWith (+) fibonacci (tail fibonacci)
這裡 fibonacci
本身是一個惰性結構,所以在計算的時候,會先算出列表前面的兩個1,得到 1 : 1...
這樣的結構,然後怎麼表達 fibonacci
的 fib(n) = fib(n - 1) + fib(n - 2)
特性呢?我們可以注意到,n - 1
和 n - 2
剛好在數列中相差一位,所以 n
可以看作是該數列錯位的相加的結果。
我們再來看一則篩法求素數。不熟悉篩法的可以先點開 wiki 去看一下該演算法的思路。下面這段程式碼是 Haskell 的一個簡單實現。
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 以內的素數。如果後面的計算要用到超過這個範圍,我們就不得不重新呼叫生成函式,重新生成一份更長的列表。這裡面的問題是:一、要主動去呼叫這個工廠函式,二、如果要複用已經計算出來的資料,手動去維護一個cache列表,勢必增加程式碼的複雜度。另外一個可能的情況是,我們預先生成了一份很長的列表,後面的計算中只用到了列表頭部的一丟丟資料,這也是極大的浪費。
惰性列表的使用增加了我們程式設計的表達能力,讓我們可以更關注資料結構本身的特性,而不是浪費時間在如何去管理堆疊上面。因為,惰性求值特性保證我們在需要一個值的時候才會去計算,所以可以自動地最小化我們的計算量,節約資源。
比如我們可以通過 lazy byteString 去讀、寫檔案,它本身不會把整個檔案載入到我們的記憶體裡面,而是按需的讀取。有的時候我們讀一個大檔案,可能只篩選出需要的前幾十條資料,卻確不得不把幾百 M 甚至上 G 的大檔案整個的放到記憶體裡面。
在 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
定義 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,增大在記憶體上的開銷。
最後,利益相關 - 有贊招前端,簡歷請投 [email protected]
。