卷 III · 懒CH 09深度 9/24

先要一口,才做一口

上一章最后说:序列可以是无限的。这一章讲它凭什么能——以及这件事让你能写出哪些原本写不出来的代码。顺带交代它的账单,因为惰性是这本书里少数几个「代价会在很远的地方冒出来」的特性。

lazy-seq无限序列描述与消费分开

▷ 先猜一下
(def xs (map slow-fn (range 1000000)))

slow-fn 是个很慢的函数,跑一次要 1 毫秒。

问:执行完这一行 def,大约花了多久?

A 大约 1000 秒——一百万次,每次 1 毫秒 B 几乎不花时间,它一次都还没调用 slow-fn C 几乎不花时间,但会占用一百万个元素的内存 D 取决于 slow-fn 有没有副作用

它什么时候才算

答案是 Bmap 返回的是一个「承诺」: 等你要第一个的时候,我再去算第一个。在那之前它什么都不做。

(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 给长序列命名,让它用完即走。

☕ 写 Java 的你

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,也不是我写错了——是一个明确的、有理由的取舍。