什麼是惰性串列#
把上一章的 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 的標準函式庫多數函式都是惰性的,所以 rest、map 都會回傳惰性串列;lazy-fibs 自然也是。
取出有限元素#
要實際使用惰性串列,得告訴它「我要多少個」:
(take 10 (lazy-fibs))
;; => (1 1 2 3 5 8 13 21 34 55)
(nth (lazy-fibs) 50)
;; => 20365011074take:取前 n 個元素nth:取第 n 個元素
只要不超出機器數值上限,要多少個都行。
def 並不會立刻計算#
(def list-of-fibs (lazy-fibs))
(take 5 list-of-fibs)
;; => (1 1 2 3 5)當 def 執行的當下,還沒有任何費氏數被計算、也沒有為費氏數配置記憶體。計算與配置只在我們真正存取那些元素時才發生。
把惰性串列想成「知道怎麼算出下一個元素的迭代器」即可。一旦那次計算發生,記憶體被配置、值被放入真正的串列。
不過嚴格來說:只有當程式仍持有那些值時,記憶體才會被保留——稍後會看到。
惰性 ≠ 無限#
把惰性串列想成無限串列是直覺但不準確的說法。它並非無限,而是無界(unbounded)——你可以走過任意有限多個元素。
惰性的累積與爆炸#
當你不斷把惰性串列丟進 map、rest、take(沒錯,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)#
惰性還有更多細節值得學習,本章只負責讓讀者知道它的存在——因為它在函式式語言中無所不在。
後續章節會繼續用到惰性,但通常它會默默躲在背景,不需要刻意提起。