CLOJURE · 六卷 · 24 章 · 24 个 DEMO

你以为你在修改一个对象。其实你只是让一个名字,指向了另一个值。
值不动,动的只有名字——这句话吃透了,并发、撤销、时间旅行、Git、React、数据库的快照读,会同时变成同一件事。

写给 Java / Kotlin 工程师 零 Lisp 基础 24 个可交互 Demo 与 Clojure 1.12.4 逐字符核对
旧根 v1 新根 v2 这一片,两个版本共用 —— 一个字节都没有复制 32,254 个节点 新建 4 个 100 万个元素的向量,改掉其中一个 旧的那一版还在,还能用

这本书只讲一件事

在 Java 里,list.add(x) 之后,那个 list 变了。所有拿着它的人手上的东西都变了, 而他们不知道。你为此付出的代价,你已经付了很多年:防御性复制、unmodifiableList、 锁、volatile、「这个对象线程安全吗」、以及那些只在线上出现的诡异 bug。

Clojure 的回答不是「小心一点」,是把这件事从根上拿掉:值不会变(conj v x) 给你一个新向量,旧的那个一个字节都没动。

你的第一反应应该是:那不是每次都要复制一份吗?那得多慢?
这本书的第 2 章就回答这个问题,用一台真的持久化向量:100 万个元素的向量改掉一个, 整棵树 32,258 个节点里只新建了 4 个,其余 32,254 个在内存里是同一批对象。 顺便说,它比复制一遍数组快 600 多倍。

卷 I

值不动

VALUES

先把一件事看清楚:你从来没有改过任何东西。你只是让一个名字指向了另一个值。

  1. 01你以为你在改它把同一个列表传进两种语言的同一个函数,回来的时候一个变了,一个没变▸ 传进去,回来变了没
  2. 02另存为:一次修改要付多少钱招牌。100 万元素改一个,只新建 4 个节点、复制 128 个引用,而不是 1,000,000 个▸★★ 真的持久化向量
  3. 03名字、值、时间:被揉成一团的三样东西Java 的「一个对象」其实是三样东西挤在一起,拆开之后并发问题少一半▸ 时间轴
  4. 04括号不是审美,是代价与红利为什么非得长成这样:读进来的不是语法树,就是你程序里用的那些列表和符号▸ 真读取器
卷 II

手上的活

HANDS

把语言本身过一遍。你会发现要背的东西少得离谱——因为大部分你已经会了。

  1. 05四种括号为什么 conj 往向量的尾巴加、往列表的头上加——它加的是「最便宜的那一端」▸ 字面量拆解台
  2. 06没有类,只有 map第 9 个键进来的那一刻,实现从数组换成哈希表,顺序当场重排▸ 第 9 个键
  3. 07解构:一行顶十行取值太便宜,是「就用普通 map」这件事能站住脚的一半原因▸ 真解构引擎
  4. 08一个接口跑遍所有容器seq 不是一个类,是一个问题:你能不能给我第一个,和剩下的▸ 七种容器同一组函数
卷 III

LAZY

序列不是集合,是一份「要的时候再算」的承诺。而这份承诺的条款你多半没读。

  1. 09先要一口,才做一口定义一个无限长的序列不花时间,因为没人要,它就一个都不算▸ realized 计数
  2. 10你以为算了 1 个,其实算了 32 个Clojure 里最容易被咬一口的地方,而它不是 bug,是一个明确的取舍▸★★ 分块
  3. 11reduce 才是本体for 循环把「遍历」和「你要做的事」焊死,reduce 把它们拆开▸ 每一步 + 早停
  4. 12把「怎么变」和「变什么」拆开同一段管道,链式写法调用 32 次,转换器调用 2 次,中间集合从 3 个变成 0 个▸★★ 2 次 vs 32 次
卷 IV

时间

TIME

并发不难。难的是你把「一个会变的东西」和「它此刻的值」当成了同一样东西。

  1. 13不变的东西不需要锁同一个交错、同一个种子:可变计数器丢掉 64 次更新,CAS 一次没丢▸★★ 丢更新
  2. 14atom:世界上最简单的并发原语它不锁,它重来——所以传给 swap! 的函数必须是纯的▸ CAS 重试计数
  3. 15ref 与 STM:转账问题的正确答案各锁各的会死锁;约定顺序要求所有人守一条没写下来的规矩;事务两样都不要▸★★ 死锁 vs 重试
  4. 16时间是一根轴,不是一个变量一边转账一边查总额:读两个位置错了 11 次,读一个值错了 0 次▸★★ 撕裂读
卷 V

扩展

EXTEND

你早就撞过这堵墙:想给一个不是你写的类加个方法。这一卷讲四种撞法。

  1. 17从 instanceof 到协议不改源码、不继承、不包装,给 String 和 nil 加上同一个方法▸ 给别人的类加方法
  2. 18表达式问题:为什么你总要改别人的代码加类型容易还是加操作容易——大多数语言只让你在两个方向里选一个开口▸ 2×2 与任意派发
  3. 19宏:在编译期改写代码一个形式进、形式出的普通函数,只不过它在求值之前跑▸ 真展开器
  4. 20什么时候不要写宏同一个宏,调用方恰好有个同名变量就出错:[1 99] 变成 [1 1]▸ 宏捕获与 gensym
卷 VI

落地

REAL

把前面五卷换成手上的活:一台不重启的 REPL、一份能生成数据的合同、一张账单。

  1. 21你从来不重启不是「有个命令行可以试代码」,是你的程序一直活着,你在它身上动手术▸★★ 一台真 REPL
  2. 22spec:不是类型,是运行期的合同它能表达「年龄小于 130」,还能反过来生成符合它的数据▸ conform / 收缩
  3. 23值真的贵吗一张老实的账单:哪些地方真的更慢,哪些地方「不可变」正是快的原因▸ 瞬变与实测
  4. 24继续往下走24 条错误直觉的自查表,往下读什么,以及这门语言真实的短板▸ 自查表

写给谁

  • 写过几年 Java 或 Kotlin,知道 ConcurrentModificationException 是怎么来的, 也写过防御性复制。
  • 没碰过 Lisp,一看见括号就有点想跑。这本书假设你的 Lisp 基础是零。
  • 不需要你打算转去写 Clojure。这本书里有一半的东西, 你明天就能用在手上那个 Kotlin 项目里。

这本书不打算做的事

  • 不劝你换语言。第 23 章会老实列出它真实的短板: 启动慢、错误信息难读、招人难、动态类型在大项目里的确要还债。
  • 不做语法速查表。要查 API 请去官方文档,这本书讲的是「为什么长成这样」。
  • 不吹「函数式更优雅」。凡是有代价的地方,这本书会把代价写出来, 包括分块惰性序列会多算 31 个元素这种事。

怎么读这本书

  • 只有一小时:读第 1、2、16 章。这三章是主线的三个落点—— 值不动、不动为什么不慢、以及它在并发上意味着什么。
  • 想上手写:读完卷 I、卷 II,然后直接去第 21 章那台 REPL 里敲。 它是真的,不是录像。
  • 被并发折磨过:卷 IV 可以单独读,四章都有能跑的模拟器。
  • 每一章的结构是一样的:开头「▷ 先猜一下」给你一道四选一, 结尾「◇ 另存一版」揭晓答案,并且明确写出你进来时的想法(旧值)和出去时的想法(新值), 以及这一章只重建了哪一条路径——这个栏目本身就在演示结构共享。

书里的数字是从哪来的

这本书配了一台引擎(assets/engine.js):一个真的 Clojure 读取器、求值器、 持久化向量与 HAMT 字典、分块惰性序列、转换器、STM、协议与宏。它不是「Clojure 风格的库」, 是照着 clojure.lang 的实际实现写的。

为了确认它没在骗你,仓库里还有一台探针(scripts/probe-clj): 它在真的 Clojure 1.12.4 上跑 470 行源码,把每一行的求值结果记下来, 再用 Java 反射走进真 PersistentVectorPersistentHashMap 内部, 按对象同一性数出「共用了几个节点、新建了几个」。

本书引擎必须对同一行源码给出逐字符相同的输出, 节点数也必须逐个相等。所以书里出现的每一个数字, 要么是引擎当场算的,要么是从真 Clojure 里量出来的——没有一个是我编的。