什麼是惰性串列#

把上一章的 memoized fib 拿來,再加幾行,可以做出一個**「無限長」的費氏數列產生器**:

(declare fib)

(defn fib-w [n]
  (cond
    (< n 1)  nil
    (<= n 2) 1
    :else    (+ (fib (dec n)) (fib (- n 2)))))

(def fib (memoize fib-w))

(defn lazy-fibs []
  (map fib (rest (range))))

這段程式裡的關鍵:

  • range(不傳參數時):回傳一個從 0 開始、長度未指定的整數串列
  • rest:去掉串列第一個元素,回傳其餘部分
  • map:把函式套用到串列每個元素,回傳新的串列

range 回傳的是惰性串列(lazy list)——一個「知道怎麼計算下一個值」的物件,骨子裡就是 Java/C++/C# 程式設計師熟悉的「迭代器(iterator)」,只是穿上了串列的外衣。

Clojure 的標準函式庫多數函式都是惰性的,所以 restmap 都會回傳惰性串列;lazy-fibs 自然也是。

取出有限元素#

要實際使用惰性串列,得告訴它「我要多少個」:

(take 10 (lazy-fibs))
;; => (1 1 2 3 5 8 13 21 34 55)

(nth (lazy-fibs) 50)
;; => 20365011074
  • take:取前 n 個元素
  • nth:取第 n 個元素

只要不超出機器數值上限,要多少個都行。

def 並不會立刻計算#

(def list-of-fibs (lazy-fibs))

(take 5 list-of-fibs)
;; => (1 1 2 3 5)

def 執行的當下,還沒有任何費氏數被計算、也沒有為費氏數配置記憶體。計算與配置只在我們真正存取那些元素時才發生。

把惰性串列想成「知道怎麼算出下一個元素的迭代器」即可。一旦那次計算發生,記憶體被配置、值被放入真正的串列。

不過嚴格來說:只有當程式仍持有那些值時,記憶體才會被保留——稍後會看到。

惰性 ≠ 無限#

把惰性串列想成無限串列是直覺但不準確的說法。它並非無限,而是無界(unbounded)——你可以走過任意有限多個元素。

惰性的累積與爆炸#

當你不斷把惰性串列丟進 mapresttake(沒錯,take 也回傳惰性串列)等函式,背後會累積一條長長的迭代器鏈

  • 每個迭代器都必須抓住「下一個值的計算函式」
  • 還必須抓住「計算所需的全部資料」

作者說過自己寫過這樣的程式:成千個元素的串列,每個元素又指向另一個成千元素的串列,全部惰性。在最終結果被存取前,背後是排山倒海的延遲計算。一旦累積超過記憶體,程式就掛了。

用 doall 強制求值#

時不時把惰性串列轉成「實列」是個好習慣:

(def real-list-of-fibs (doall (take 50 (lazy-fibs))))

doall 把所有延遲計算一次完成,回傳一個佔據實際記憶體、不再含有任何延遲迭代器的真正串列。

為什麼還是要惰性#

惰性的成本——延遲計算的記憶體與週期、累積導致的記憶體耗盡——並不便宜。

那為何幾乎所有函式式語言都採納它?

  • Haskell:內建全面惰性
  • Clojure:核心並非惰性,但太多函式庫函式是惰性的,幾乎避不開
  • F#/Scala:允許惰性,但需明確標示

惰性把「要做什麼」與「要做多少」解耦。

你可以寫一個產生惰性序列的程式,完全不用知道使用者要多少個元素;使用者自己決定要取多少。

解耦的力量#

(nth (lazy-fibs) 500)

回傳第 500 個費氏數——一個 100 多位的天文數字。lazy-fibs 沒有在程式碼裡寫死任何上限,因此可以任意取用。

再看一個對照:

;; 寫法一:上限「綁死」在 range 的呼叫
(range 51)

;; 寫法二:上限「外置」於 take,與 range 解耦
(take 51 (range))

寫法一中,「51」必須以參數形式塞進 range——一個強耦合。 寫法二中,range 對 51 一無所知;51 可以待在程式遠處。

自動釋放歷史值#

回到前面的 lazy-fibs 例子:當我們呼叫 (nth (lazy-fibs) 500) 時:

  • 中間計算過的 (fib 1)(fib 499) 多半已經被垃圾回收
  • 因為我們沒有抓住整個串列,runtime 可以自由丟棄已經算過的元素
  • 因此可以走訪一個元素數量達兆級的惰性串列,任何時候只在記憶體中保留少量元素

結語(Coda)#

惰性還有更多細節值得學習,本章只負責讓讀者知道它的存在——因為它在函式式語言中無所不在。

後續章節會繼續用到惰性,但通常它會默默躲在背景,不需要刻意提起。