卷 VI · 落地CH 23深度 23/24

真的贵吗

前面二十二章一直在说不可变有多好。这一章摊账单:哪些地方它确实更慢、慢多少、有什么逃生舱。也包括一个不太好意思的问题——第 2 章那个「快 600 多倍」,是不是挑了个对自己有利的比法?

瞬变缓存局部性老实的对比

▷ 先猜一下

三件事,各做一百万次:

① 往一个集合末尾加一个元素(做一百万次)
② 随机读取第 i 个元素
③ 遍历全部元素求和

问:和 Java 的 ArrayList / int[] 比, Clojure 的持久化向量在哪一项差距最大?

A ① 加元素——每次都要新建节点 B ② 随机读取——要走 4 层树,而数组是一次寻址 C ③ 遍历求和 D 三项差不多,都在 2 到 3 倍之间

先给结论

答案是 C:遍历,而且这个答案对大多数人是反直觉的。

操作            持久化向量 vs 原生数组      为什么
──────────────────────────────────────────────────────────────
末尾加元素      接近,有时更快              尾巴优化,多数时候只碰 32 格
随机读第 i 个    慢 2–3 倍                   走 log₃₂(n) 层,一百万个元素是 4 跳
遍历求和        慢 3–10 倍                  ★ 缓存不友好,见下
中间插入 / 删除  快得多                      数组要挪动后面所有元素
「改一个,旧的还要」 快几百倍                  数组必须整个复制一份(第 2 章)

为什么遍历最吃亏?因为缓存

int[] 在内存里是连续的一整块。 CPU 读第一个元素时会顺手把后面 64 字节(一整条缓存行)拉进来, 于是接下来十几个元素都是免费的。硬件的预取器还会继续往后猜。

持久化向量是一堆分散的 32 格小数组, 彼此之间在内存里没有连续关系。每跳到下一个叶子, 很可能就是一次缓存未命中。

这是不可变数据结构最真实、最难绕开的一笔成本, 而且它不会出现在复杂度分析里——两边都是 O(n), 常数差了一个数量级。

◆ 可以带走的判断

不可变的代价不在「复制」(第 2 章证明了不用复制), 在间接寻址和缓存局部性。 你付的是访问速度的常数因子,买的是共享、并发安全和时间旅行

逃生舱:瞬变

有一类场景,不可变的成本纯属浪费:你在造一个新集合的过程中

;; 造一个一百万元素的向量
(reduce conj [] (range 1000000))

这里产生了一百万个中间版本,而这些中间版本没有任何人看见过—— 它们生下来就是为了立刻被下一次 conj 取代。为它们维护结构共享, 是在为一份没人要的保证付钱。

transient 就是为这个准备的:

(persistent!
  (reduce conj! (transient []) (range 1000000)))

瞬变的规矩只有一条:transientpersistent! 之间,别让任何人看见它。在这段窗口里它允许就地修改 (因为没有别人持有引用,改了不会影响任何人); persistent! 一调用,它立刻变回不可变的值,再改就报错。

三条使用要点:

  • 必须用返回值。(conj! t x) 返回的可能是 另一个对象,不能只依赖副作用。写 (reduce conj! t xs) 就自然对了。
  • 不能跨线程。瞬变绑定在创建它的线程上。
  • 只用在局部。如果它逃出了当前函数,说明你用错了。

好消息是:intomapvfiltervfrequenciesgroup-by 这些函数内部已经用了瞬变。 所以大多数时候你不需要手写——用 into 就已经是快的那条路了。

关于第 2 章那个 600 倍

现在回答那个不太好意思的问题。第 2 章的对比是:

100 万元素向量 assoc 一个       0.0017 ms
100 万元素的数组整个复制一遍     1.04   ms

这个比法公平吗?要分情况说:

如果你需要「改完之后旧版本还能用」——公平,而且是唯一的比法。 数组要满足这个需求,除了整个复制没有别的办法。 这正是防御性复制、快照、撤销、并发读所要求的场景。

如果你不需要旧版本——不公平。 arr[i] = x 是一次内存写,几个纳秒, 比 assoc几十倍。这时候持久化向量纯属多余。

所以老实的说法是:

持久化数据结构不是「更快的数据结构」, 是「让『保留旧版本』这件事从 O(n) 降到 O(log n)」的数据结构。 如果你根本不需要旧版本,它只会更慢。

而这本书的立场是:你需要旧版本的次数,比你以为的多得多—— 每一次防御性复制、每一次「这个对象线程安全吗」、 每一次撤销、每一次快照读,都是在需要它。 只是在可变的世界里,这个需求被伪装成了别的问题。

什么时候老老实实用可变

Clojure 从来没禁止你用数组。该用就用:

;; 数值计算、图像处理、紧凑循环
(let [arr (double-array 1000000)]
  (dotimes [i 1000000]
    (aset arr i (Math/sin i)))       ;; 就地写,和 Java 一样快
  (areduce arr i ret 0.0 (+ ret (aget arr i))))

判断标准很简单:

用不可变,如果……                     用可变,如果……
─────────────────────────────────────────────────────────────
数据要被多处共享                       完全局部,出不了这个函数
需要并发访问                           单线程紧凑循环
需要保留历史 / 快照 / 撤销              只关心最终结果
形状复杂、嵌套深                       一大块同类型的数字
性能不是这段代码的瓶颈                 这里就是热点,而且量过

最后一行最重要:先量,再优化。 绝大多数应用的瓶颈在 I/O、在数据库、在网络, 不在于你的 map 是不是持久化的。

☕ 写 Java 的你

你其实已经在为不可变付过一次钱了,而且付得心甘情愿: String 是不可变的

Java 为此付出了实实在在的成本:每次拼接产生新对象, 所以才需要 StringBuilder(这就是 Java 版的 transient—— 在局部可变,最后 toString() 变回不可变, 和 transient / persistent! 是同一个模式)。

但没有人认为「String 不可变」是个错误设计。 因为它买到的东西太值了:可以放心传递、可以当 map 的 key、 可以缓存哈希、线程安全、不用防御性复制。

这本书要说的,无非是把这笔交易从 String 推广到所有数据。

▸ 在现实里
  • JVM 的逃逸分析:短命的小对象经常根本不会被分配到堆上, 所以「产生很多中间对象」在现代 JVM 上没有听起来那么可怕。
  • 分代垃圾回收:年轻代的回收成本约等于「活着的对象数」, 而不是「死掉的对象数」。大量立刻死掉的中间版本,回收起来几乎免费。 这是函数式风格能在 JVM 上跑得动的一个关键前提。
  • Android:这里要谨慎一些。 内存压力更大、GC 停顿更敏感,热路径上(比如列表滚动时的每一帧) 确实要小心分配。这不是不能用,是要量。
✗ 这个直觉是错的

「既然不可变有性能代价,那就在性能敏感的地方全用可变, 其他地方用不可变。」

方向对,但边界画错了。正确的边界不是「性能敏感 / 不敏感」, 而是「这个数据会不会被别人看见」

;; ✓ 局部可变,出去时是值 —— 这是最佳实践
(defn build-index [rows]
  (persistent!                              ;; ← 出门前变回不可变
    (reduce (fn [acc row] (assoc! acc (:id row) row))
            (transient {}) rows)))          ;; ← 内部随便快

;; ✗ 把可变的东西暴露出去 —— 你会重新拥有第 1 章的所有问题
(def cache (java.util.HashMap.))            ;; 谁都能改,什么时候改不知道

这个模式有个名字,叫「局部可变,边界不可变」。 它让你在函数内部拿到可变的全部速度, 同时在函数外部保住值的全部好处。Java 的 StringBuilder 就是这个模式。

◇ 另存一版

答案是 C:遍历,慢 3–10 倍, 原因是缓存局部性——分散的 32 格小数组打不过一整块连续内存。

A 「加元素最慢」——恰恰相反,这是它最强的项之一。 尾巴优化让多数 conj 只碰一个 32 格数组 (第 2 章量过:尾巴没满时新建 0 个节点)。

B 「随机读最慢」——确实慢,但只慢 2–3 倍: 4 跳指针在有缓存的情况下并不算贵。 大多数人会选 B,因为「走 4 层树」听起来比「遍历」更费事—— 而真正的差距藏在硬件层面,不在算法层面。

D 「三项差不多」——不同项的差距是不一样的, 知道差在哪一项才知道该在哪儿用逃生舱。

你进来时(第 2 章):不可变几乎没有代价, 因为它根本不复制。

你出去时:代价不在复制,在间接寻址和缓存局部性; 逃生舱是瞬变;而「快 600 倍」那个对比只在你需要保留旧版本时才公平。

第 2 章的结论完全没被推翻——那些节点数是实测的, 那个场景也是真实的。这一章只重建了一条路径: 加上了「在什么前提下」这个限定

这一章的一句话

不可变的代价不是复制,是缓存; 而「快几百倍」只在你需要保留旧版本时成立——只不过这种时候比你以为的多。

最后一章收尾:24 条错误直觉的自查表、 往下该读什么、这门语言真实的短板, 以及一件更重要的事——这本书里有哪些东西, 你明天就能用在手上那个 Kotlin 项目里,而完全不需要换语言。