你以为算了 1 个,其实算了 32 个
上一章说「要几个算几个」。这一章要收回这句话的一半。同样的代码,只把序列的源头换一个,调用次数从 1 变成 32。这是 Clojure 里最容易被咬一口的地方——而它不是 bug。
(def n (atom 0)) (first (map (fn [x] (swap! n inc) x) (range 100))) @n
我们只要了第一个元素。
问:@n 是多少?(也就是那个函数被调用了几次)
再猜一个:如果把 (range 100) 换成 (iterate inc 0),
答案会变吗?
先看数
答案是 C,32。而换成 (iterate inc 0) 之后,
答案变成 1。同样的 map,同样只要一个,
只换了源头,差了 32 倍。
这不是我编的,也不是这本书引擎的怪癖——
这一行在真 Clojure 1.12.4 上跑出来就是 32
(scripts/probe-clj 里有原始记录)。自己拨拨看:
把 take 拨到 33,你会看到 64。拨到 1 到 32 之间任何一个数, 都是 32。这个台阶状的曲线就是这一章的全部内容。
为什么要这样
回到第 9 章末尾那句话:惰性在每个元素上都加了一层包装的开销。
具体是什么开销?每个元素都要:分配一个 LazySeq 对象、
一次函数调用去「兑现」它、一次同步检查(因为可能多线程同时兑现)、
再分配一个 cons 节点。为了拿到一个整数,你付了三四个对象。
对于 (range 100) 这种「反正元素都在那儿、算起来极便宜」的源头,
这个开销比干活本身还贵得多。
于是 Clojure 做了一个决定:对于能成批供货的源头,一次算 32 个,攒成一块再交出去。
不分块: 要 1 个 → 1 次包装开销 + 1 次计算
要 32 个 → 32 次包装开销 + 32 次计算
分块: 要 1 个 → 1 次包装开销 + 32 次计算 ← 多算了 31 个
要 32 个 → 1 次包装开销 + 32 次计算 ← 省了 31 次包装
这笔账在绝大多数场景里是划算的:你很少只要一个元素就扔掉整个序列, 而「一次包装」比「一次简单计算」贵得多。
分块是拿「有时多算 31 个」换 「几乎所有时候少 31 次包装」。 它是一个性能取舍,不是语义保证——所以你不能依赖「只算了我要的那些」。
谁分块,谁不分块
规则大致是:能随机访问、或者本来就成段存着的源头会分块。
源 分块吗 要 1 个算几次 ────────────────────────────────────────────────── (range 100) 分块 32 (vec (range 100)) 分块 32 (apply list (range 100)) 不分块 1 (iterate inc 0) 不分块 1 自己写的 lazy-seq 不分块 1
可以当场问它:
(chunked-seq? (seq (range 100))) ;; => true (chunked-seq? (seq (list 1 2 3))) ;; => false (chunked-seq? (seq (map inc (range 100)))) ;; => true ← 会传染
最后一行值得注意:分块会往下游传染。
map 收到一块 32 个,就整块算完,再交给下游一块 32 个。
所以链条上任何一环都保持着 32 的节奏。
什么时候它会真的咬你
只有一种情况:序列里的函数有副作用。
;; 你以为:只发一个请求 (first (map #(http-get %) urls)) ;; 实际:如果 urls 是向量或 range,发了 32 个请求
其它几个真实的伤法:
(first (map #(insert-db! %) rows))—— 多插了 31 行。(take 1 (map #(charge-card! %) orders))—— 这个不用解释。- 带
take-while的提前退出:你以为遇到条件就停了, 实际当前这一块已经整块算完了。
三条解法,按推荐顺序:
- 别在惰性序列里放副作用。这是根治。
要副作用就用
doseq、run!、 或者reduce——它们语义明确,不惰性。 - 要逐个处理就换不分块的源,比如把向量转成列表,
或用
(sequence (map f) coll)。 - 用转换器:
(into [] (comp (map f) (take 1)) coll)只会调用 1 次。这是下一章之后第 12 章的主角, 而这个「2 次 vs 32 次」的对比就是那一章的招牌。
「惰性」不等于「按需」。准确说法是 「不早于需要的时候算」——它承诺不提前,但没承诺不多算。
这个区别听着像抠字眼,但它正好划出了安全线: 惰性可以放心用来省掉不需要的计算(性能), 但不能用来控制副作用发生的次数(正确性)。
「批量摊薄固定开销」这个模式到处都是,而它到处都带着同一个副作用:
- 磁盘和网络:你读 1 个字节,操作系统读了一整页 4 KB; 你取一条 Kafka 消息,客户端预取了一整批。
- CPU 缓存行:你读一个 int,CPU 拉了 64 字节。 这就是为什么按行遍历数组比按列快——和这一章是同一件事。
- 数据库游标:JDBC 的
fetchSize默认就不是 1。
每一个都在做同样的交易,也都有同样的后果: 你观察到的「我只要了一个」和系统实际做的事对不上。 分块序列只是把这件事搬进了语言层面。
「惰性序列保证只计算我实际用到的元素, 所以可以放心用它来控制副作用什么时候发生。」
惰性只保证不比需要更早,不保证不比需要更多。
分块的源一次算 32 个。要精确控制副作用,用 doseq / run! /
reduce,它们本来就是为这个准备的。
顺便说,这条直觉的反面版本也是错的: 「所以惰性序列不可靠,别用了」——不对。 纯函数配惰性序列是完全安全的,多算 31 次纯计算不改变任何结果。 危险的从来不是惰性,是惰性 + 副作用这个组合。
《撞墙》(排队与拥塞):那本书讲「批量」是怎么用摊薄固定成本 换来吞吐的,也讲了它的代价——延迟的方差变大。 这一章是同一笔交易的语言版本:平均更快,但个别请求会多干 31 份活。
《独占》(操作系统):读一个字节触发一整页 I/O, 那本书里有价目表。看完你会觉得 32 这个数字很亲切—— 它和页大小、缓存行是同一族的常数。
答案是 C,32。换成 (iterate inc 0) 之后是 1,
因为 iterate 产生的是一节一节的链,没法成批供货。
A 「只算第一个」——这是第 9 章教给你的模型, 而它对不分块的源完全正确。这道题的意义就在于告诉你这个模型有个星号。
B 「100,全算完」——那就不叫惰性了。 分块是「一块一块地懒」,不是「不懒」。要 33 个时它算 64 个而不是 100, 正好证明它还是懒的。
D 「0,还没被使用」——first 就是使用。
惰性序列在被问到 first 的那一刻就必须兑现,
它只是把「兑现」推迟到那一刻,不是永远不兑现。
你进来时:惰性序列要几个算几个,可以用它精确控制什么时候干活。
你出去时:惰性保证「不早于」,不保证「不多于」; 分块的源一次算 32 个,所以惰性能用来省计算,不能用来管副作用。
第 9 章关于「描述与消费分离」的结论完全成立, 无限序列、按需取数这些好处一个没丢。 这一章只重建了一条路径:「按需」这个词的精确含义。
这一章的一句话
惰性承诺的是「不早于」,不是「不多于」。 分块的源一次算 32 个——所以副作用不要放进惰性序列。
下一章往回退一步,去看一个比 map 和 filter 更基本的东西。
你会发现前面用过的几乎所有序列函数,都可以用同一个函数写出来;
而认出这一点之后,第 12 章那个「2 次 vs 32 次」的把戏才有地方落脚。