你从来不重启
你对 REPL 的印象大概是「一个可以试代码片段的命令行」。那是 Python 的 REPL。Clojure 的 REPL 是另一种东西:你的程序一直活着,你在它身上动手术——改一个函数、试一下、再改,中间从不重启。
你在调一个 bug:某个函数在处理第 4,721 条数据时出错。 为了复现,程序要先加载一个大文件、建立数据库连接、跑完一堆初始化, 整个过程要 40 秒。
你改了一行代码。问:接下来这一轮调试要多久?
顺带一问:那 4,721 条数据里出问题的那一条, 你手上还有吗?
先玩一下
下面这台是真的求值器(就是这本书的引擎,第 1 章到现在所有代码都是它跑的)。 按顺序点那四个按钮,注意看发生了什么:
你刚才做的事:定义了一个函数、用了它、 在程序还活着的时候把它换成了另一个实现、再用一次。
中间没有重启。上一步定义的东西还在,
(def a (atom 0)) 之后 a 里的值也还在。
差别在哪:编辑-编译-运行 vs 一直活着
你现在的循环大概是这样:
改代码 → 编译 → 启动 → 点到出问题的界面 → 观察 → 关掉 ▲ │ └──────────────────────────────────────────────────┘ 每一圈:几十秒到几分钟,而且每一圈都要<把现场重新搭一遍>
REPL 驱动是这样:
启动一次(40 秒) ↓ 把现场准备好:加载数据、连上库、跑到出问题的那一刻 ↓ 改一个函数 → 发送到 REPL(0.1 秒)→ 用现场的数据再调一次 → 看结果 ▲ │ └────────────────────────────────────────────────────────┘ 每一圈:一两秒,而且现场一直在
关键不是「快」,是「现场还在」。
那条出问题的第 4,721 号数据,在 REPL 里就绑在一个变量上, 你可以反复拿它去喂改了又改的函数。而重启一次,它就没了—— 你得重新跑到那儿去。
真实的工作流长什么样
具体一点。假设一个 HTTP 接口返回的数据有问题:
;; 1. 程序已经在跑着。先把出问题的那个请求抓下来,钉在一个变量上
(def bad-req (first (filter #(= 500 (:status %)) @recent-requests)))
;; 2. 现在可以反复拿它试了
(handle-order bad-req)
;; => 炸了,说 :items 是 nil
;; 3. 看看它到底长什么样
(keys bad-req)
;; => (:id :customer :line-items) ← 啊,字段名是 line-items
;; 4. 改函数,重新求值这个 defn(不重启)
(defn handle-order [req]
(let [items (or (:items req) (:line-items req))] ;; 兼容一下
...))
;; 5. 立刻用同一条数据再试
(handle-order bad-req)
;; => 通过
;; 6. 顺手写成测试
(deftest handles-line-items ...)
整个过程里程序一直在跑,bad-req 一直在手上。
第 4 步改完函数之后,连正在处理请求的那些线程都会用上新版本——
因为函数是通过一个 var(第 3 章说的那种「身份」)找到的,
重新 def 就是把那根指针指向新函数。
看到了吗?这一章其实是第 3 章的应用: 函数名也是一个身份,它此刻指着哪个实现,是可以换的。
为什么别的语言难做到
热替换在 JVM 上是有的(HotSwap、JRebel),但受限很多: 改方法体可以,改方法签名、加字段、改类结构就不行,要重启。
根本原因是状态和代码绑在一起:
你的 OrderService 实例里存着状态,
而类结构一变,那些实例就不合法了,只能扔掉重建。
扔掉重建就意味着状态没了。
Clojure 这边:
- 函数是无状态的,换掉一个函数不影响任何数据。
- 数据是值,不属于任何类,不会因为代码变了而失效。
- 状态集中在少数几个 atom 里,你可以选择保留或重置。
所以「换掉代码但保住状态」在这里是默认行为,不是需要特殊工具的特技。
你最接近的体验是调试器的断点:程序停在那儿, 你可以看变量、可以求值表达式(Evaluate Expression)、 甚至能改一点代码(HotSwap)。
REPL 相当于整个程序永远处在断点状态, 而且你手里那个「求值表达式」的窗口就是你的编辑器本身。
还有一个实际区别:调试器里你写的东西是一次性的, 试对了还得回编辑器再写一遍。REPL 里你就在编辑器写, 写对的那一行本来就是源代码——试验和写代码是同一个动作。
它的代价,以及一个真实的坑
这本书答应过不吹。REPL 驱动有一个非常实在的问题:
REPL 里的状态会和源文件不一致。
你在 REPL 里定义了一个函数,后来把源码里那段删了, 但 REPL 里那个定义还在。于是你的程序在 REPL 里跑得好好的, 重启之后就炸——因为它依赖着一个只存在于内存里的定义。
这个现象常被叫做「REPL 状态漂移」。应对办法:
- 定期重启一次 REPL,确认从干净状态能起来。
- 用
tools.namespace之类的工具重新加载整个命名空间。 - 别把「只在 REPL 里存在」的东西当成代码的一部分—— 重要的东西要落到文件里。
另一个代价是它需要工具支持: 编辑器得能把光标处的表达式发给 REPL。 Emacs(CIDER)、VS Code(Calva)、IntelliJ(Cursive)都能做, 但要配一下,而且这个工作流需要一两周才能形成肌肉记忆。
- NASA 的深空一号(1998):飞船上跑着 Common Lisp, 地面工程师通过 REPL 连上去,在一亿五千万公里外调试并修复了代码。 飞船没有「重启一下试试」这个选项。
- Erlang / Elixir:热更新是它的核心卖点之一, 电信设备要求「九个九」的可用性,根本不能停机。 书架上的《九个九》整本讲这件事—— 那本书的最后是连进一个活着的生产节点掏状态, 和这一章是同一个动作。
- 前端的热重载(HMR):你已经在用了。 改一行 CSS 页面立刻变,而且表单里填的内容还在—— 那个「内容还在」就是这一章的全部意义。 Figwheel 把这件事带到了 ClojureScript,而且能保住整个应用状态。
「REPL 就是一个交互式命令行,用来快速试试语法。 真正干活还是要写文件、跑测试。」
那是把 REPL 当计算器用。 Clojure 的用法是反过来的:你的程序运行在 REPL 里, 你的编辑器是操作它的手。
一个具体的差别:Python 的 REPL 里你敲的东西和你的项目基本是两个世界; Clojure 的 REPL 连的就是你正在开发的那个应用进程—— 它加载着你所有的命名空间,持有着真实的数据库连接和缓存。
所以「在 REPL 里试对了再写进文件」这个顺序也是反的。 正确的顺序是:写在文件里,然后按一个键把这一段发过去执行。 你写的一直是源代码。
答案是 C,大约 1 秒:只重新求值改动的那个函数。
第二问:还在——那条数据就绑在一个变量上,
你可以拿它反复试,这才是省下的大头。
A / B 「重启一遍」——这是编辑-编译-运行循环的思维。 它假设「代码变了 = 世界要重来」,而这个假设的根源是 状态和代码绑在一起(第 3 章)。
D 「看用不用热重载工具」——这个答案在 Java 世界是对的, 因为那边热替换确实是个需要额外工具、而且限制很多的特技。 在 Clojure 里它是默认行为:重新 def 一个函数, 就是把那根指针指过去,没有别的机制。
你进来时:REPL 是个试代码的命令行; 改了代码就要重启,这是天经地义的。
你出去时:程序可以一直活着, 因为函数是无状态的、数据是值、状态集中在少数几个身份里—— 换掉代码不会让任何数据失效。
第 3 章的身份/值一个字没变, 这一章只是把它用在了函数名上: 函数名也是一个身份,它指着哪个实现是可以换的。
这一章的一句话
REPL 不是试代码的命令行,是「你的程序一直活着」这件事本身。 而它成立的原因是:换掉代码不会让任何数据失效。
下一章回去还第 6 章欠的那笔账。 当时说「就用普通 map」的代价是:打错字不报错、形状不写在代码里、IDE 帮不上忙。 Clojure 的回答不是类型系统,是一份写成数据的、运行期的合同—— 而且这份合同能反过来生成符合它的数据, 顺手把一个反例收缩成最小的那一个。