先要一口,才做一口
上一章最后说:序列可以是无限的。这一章讲它凭什么能——以及这件事让你能写出哪些原本写不出来的代码。顺带交代它的账单,因为惰性是这本书里少数几个「代价会在很远的地方冒出来」的特性。
(def xs (map slow-fn (range 1000000)))
slow-fn 是个很慢的函数,跑一次要 1 毫秒。
问:执行完这一行 def,大约花了多久?
它什么时候才算
答案是 B。map 返回的是一个「承诺」:
等你要第一个的时候,我再去算第一个。在那之前它什么都不做。
(def xs (map slow-fn (range 1000000))) ;; 立刻返回 (first xs) ;; 这时才算,而且只算需要的那些
下面这台 demo 给 map 里的函数装了个计数器。
拖动滑杆改变「要几个」,看它到底调用了几次:
要 3 个就调用 3 次,要 10 个就调用 10 次。而序列的源头是
(iterate inc 1)——一个无限长的序列。
无限序列不是噱头
「无限」听起来像炫技,但它解决的是一个非常实际的问题: 写代码的人不知道用的人要多少。
看一个具体例子。要「前 10 个质数」:
(defn prime? [n]
(and (> n 1)
(not-any? #(zero? (mod n %)) (range 2 (inc (int (Math/sqrt n)))))))
(def primes (filter prime? (range))) ;; 所有质数,无限个
(take 10 primes) ;; => (2 3 5 7 11 13 17 19 23 29)
(take 3 primes) ;; => (2 3 5)
(nth primes 100) ;; => 547
primes 这个定义里没有出现「10」。
它描述的是「所有质数」这个概念本身,
而「要几个」是使用它的人在使用时才决定的事。
在没有惰性的语言里,你只能写 fun primes(n: Int)——
把「要多少」这个参数硬塞进定义里。于是每个调用点都得先想清楚要多少,
而且中途想多要一点就得重算。
惰性把「结果是什么」和「要多少」拆成了两件事, 分别由定义方和使用方决定。这和第 12 章转换器要做的事是同一个方向: 把原本焊在一起的两个决定拆开。
它是怎么做到的
机制简单得出奇。lazy-seq 把一段代码包起来,
并且承诺「不到最后一刻不执行」:
(defn my-map [f coll]
(lazy-seq ;; ← 整个函数体被推迟
(when-let [s (seq coll)]
(cons (f (first s)) ;; 算这一个
(my-map f (rest s)))))) ;; 剩下的,还是一个 lazy-seq
看清楚递归那一行:它没有立刻算剩下的, 而是又返回一个 lazy-seq。于是整个链条像多米诺骨牌一样, 推一张倒一张,你不推就一张都不倒。
算过的那一段会被记住(缓存),所以第二次遍历不会重算:
(def xs (map slow-fn (range 10))) (first xs) ;; 慢,算了 (first xs) ;; 快,直接给缓存的结果
账单
惰性不是白来的。它有三笔实实在在的成本,Clojure 社区讨论了十几年。
一、错误在别的地方冒出来。
(defn load-all [] (map parse (read-lines "data.txt"))) ;; 文件在这里打开 (let [xs (load-all)] ;; 这里还没读 ...) ;; 文件在这里已经关了 (first xs) ;; ← 现在才读:文件已关,炸在这里
异常的栈会指向 first 那一行,
而真正的问题在 load-all。所有涉及资源(文件、连接、事务)的地方,
惰性都可能让你在资源关闭之后才去用它。
解法是明确的:在资源作用域内强制求值,
用 doall(要结果)或 dorun(不要结果只要副作用)。
二、副作用的执行时机不由你定。这一条第 10 章会用一个具体数字砸给你看。
三、内存:抓住头,就抓住了全部。
(def all (map f (range 1e8))) ;; ← def 让这个名字一直拿着序列的头 (last all) ;; 遍历过的每一个元素都被缓存着,回收不掉 → 内存爆
因为算过的会缓存,而 all 这个名字一直抓着链条的头,
于是整条链都没法被回收。这个问题有个专门的名字叫「head retention」。
解法是别用 def 给长序列命名,让它用完即走。
Stream 也是惰性的,中间操作不执行,直到遇上终端操作。
两个关键差别:
- Stream 只能用一次,用完就废;lazy-seq 是值,可以反复用、可以存起来。
- Stream 不缓存;lazy-seq 算过的会记住。 这是各有代价的取舍:Stream 不会有 head retention, 但你也没法「再遍历一遍」。
Kotlin 的 Sequence 更接近,同样是惰性、同样可以无限
(generateSequence),同样不缓存。
如果你用过它,这一章对你是免费的。
- 分页 API:把「翻页取数据」包成一个惰性序列,
调用方写
(take 50 all-users), 底下自动翻了几页、什么时候停,它不用管。 - 日志和大文件:
line-seq让你用处理小集合的写法 处理一个 10 GB 的文件,内存里始终只有几行。 - 数据库游标:同样的形状。也同样有那个坑—— 连接关了之后再去取下一批,就炸。
「惰性就是延迟计算,所以它总能省时间: 算不到的部分就是省下来的。」
惰性省的是「没被要的部分」, 但它在每个元素上都加了一层包装的开销:一个对象、一次检查、一次同步。 如果你本来就要全部元素,惰性只会更慢。
;; 要全部结果,还要装回向量:这时惰性纯属浪费 (into [] (map inc) v) ;; 转换器,没有中间的惰性序列 ← 第 12 章 (mapv inc v) ;; 直接给向量,也不惰性
正因为这层开销真实存在,Clojure 才加了「分块」这个优化—— 而分块正是下一章那个坑的来源。下一章的怪事, 根子就在这一章的这句话里。
答案是 B:几乎不花时间,slow-fn 一次都没被调用。
A 「一百万次,1000 秒」——这是把 map 当成了「立刻做一遍」。
在 Java 的 List.map 里这是对的,在这里不是。
C 「不花时间但占一百万元素的内存」——不占。
没算的元素不存在,序列此刻只有一个「怎么往下算」的说明书。
不过:如果你之后遍历完整条链,而 xs 这个名字还抓着头,
那时它就真的会占满内存——这就是上面说的 head retention。
D 「取决于有没有副作用」——副作用不影响这一行的耗时, 但它决定了这段代码有多危险:副作用会在你不知道的时刻发生, 这正是第 10 章的主题。
你进来时:map 是「把一个集合变成另一个集合」。
你出去时:map 是「描述一个变换」,
什么时候算、算几个,由要它的人决定。
第 8 章的 seq 抽象一个字都没变——惰性序列之所以能无缝接入几百个函数, 正是因为它同样只需要回答 first/rest。这一章只是把「什么时候算」这条路径重建了。
这一章的一句话
惰性把「结果是什么」和「要多少」拆给两方决定; 代价是算的时刻不由你定——这件事会在下一章咬你一口。
下一章是这本书里最容易让人「啊?」出声的一章。
上面那台 demo 里,你要 3 个它就算 3 次,看起来天经地义。
但我们把序列的源头从 iterate 换成 range,
同样要 1 个,它会调用 32 次。
这不是 bug,也不是我写错了——是一个明确的、有理由的取舍。