暗无天日

=============>DarkSun的个人博客

读:Clojure 搭车客指南

Carin Meier 在 2014 年写了一组三篇文章,标题叫"Hitchhiker's Guide to Clojure"(Clojure 搭车客指南)。标题致敬了《银河系漫游指南》(The Hitchhiker's Guide to the Galaxy),内容也用了同样的写法——用科幻小说讲编程概念。主角是两个角色:Amy,一个快要被开除的 Pascal 程序员;Frank,一个时间旅行者,腰间别着蓝色腰包,手持一个叫"求值器"的设备。他们的世界即将被毁灭,而拯救世界的方式是理解 Clojure。

这篇文章是对原文三部曲的解读。我会保留故事的叙事框架,同时把每个比喻对应的 Clojure 概念讲清楚。

第一章:开开心果与 s-expression

故事从 Amy 的办公室开始。她的老板命令她下午 3:05 部署代码,但现在是 3:00,她刚意识到一旦部署,所有辛苦收集的"喉音唱法对番茄生长速度影响"的数据都会被清空。她焦虑得疯狂剥开开心果吃。

《Clojure 搭车客指南》上说,开心果是"大自然中最完美的 s-expression"。

s-expression(符号表达式)是 Lisp 系列语言的基本数据结构。它要么是一个原子(不可再分的最小单位),要么由多个 s-expression 递归组成。开心果的壳就是外层的括号,里面的果仁就是原子。在 Clojure 里,原子求值后等于它自己:

"hi"   ;; => "hi"
1      ;; => 1
true   ;; => true
nil    ;; => nil

所以如果你的开心果里发现了 nil ——指南的建议是,感谢你得到了一个"代表无的值",然后再去拿一颗新的。

在 Clojure 中,s-expression 用括号来写。括号里的第一个元素是函数,其余是参数,参数本身也可以是 s-expression:

(+ 1 2)       ;; => 3
(+ 1 (+ 2 2)) ;; => 5

继续用开心果打比方。假设果仁有一个名字,我们定义一个函数把果仁变成"红色":

(defn red [nut]
  (str "red " nut))

(red "nut1")  ;; => "red nut1"

如果在表达式前面加一个引号(单引号 ~'~),它就不再被求值,而是变成一个列表——这就是 Lisp 的"代码即数据"特性:

'(red "nut1")            ;; => (red "nut1")

(first '(red "nut1"))    ;; => red
(last '(red "nut1"))     ;; => "nut1"

引号让代码变成了可以被操作的数据。你可以用 first 取出函数名,用 last 取出参数。但如果你把一个字符串放在括号第一位(没有函数),求值器就会报错——因为字符串不是函数:

("nut1")
;; => ClassCastException java.lang.String cannot be cast to clojure.lang.IFn

这时候 Frank 登场了。他是 Datomic(Clojure 生态的数据库)时间旅行者,带来一个消息:世界其实是由 Datomic 的 datom(数据原子)构成的。Datomic 里,每一条数据都是一个"事实"(fact),比如"这朵花是红色的"。撤销(retract)一个事实,它就从数据库里消失了。而现在,有人要提交一个事务(transaction),把世界上所有事实一次性全部撤销。结果就是一切失去所有属性,什么都不复存在。

Amy 的反应是:"Clojure?不是那个括号特别多的语言吗?"

Frank 递给她那本《Clojure 搭车客指南》,封面印着一行大字:*Don't Worry About the Parens*(别担心括号)。翻开第一页:

"人类第一个真正重要的发现不是火,而是 Paredit。Paredit 模式会自动插入和平衡括号,以至于你根本看不见它们。你周围的世界就是由 Clojure 构成的——几十亿个括号就在你周围,在你的茶杯旁边——但你没看见。这就是 Paredit。"

第二章:递归、惰性序列与时间旅行

Frank 按下求值器上的大红按钮,两人被一个 Datomic 事务带着穿越了时空,最终落回了 Amy 的办公室。一切看起来都一样——除了 Part 1 里 Amy 吃开心果留的一地果壳不见了,电脑上的日期显示的是昨天早上 8 点。他们回到了 Amy 上班之前。

Amy 说:"我马上就要来上班了。"他们得赶紧离开。

指南解释说,Frank 腰间的蓝色腰包是"时间特工"的执照标志,颜色代表等级(按彩虹 ROYGBIV 排序)。这个等级制度源于历史上一桩尴尬的事故——"大洪水"。

一个初级时间特工想用递归函数给干旱的番茄田浇水。正确的做法是这样的:

(defn rain [days]
  (when (pos? days)
    (println (str "Rain: " days))
    (rain (dec days))))

(rain 5)
;; 输出:
;; Rain: 5
;; Rain: 4
;; Rain: 3
;; Rain: 2
;; Rain: 1

关键在于 (dec days) — —每次递归调用时把参数减 1。但他犯了新手最经典的错误:忘了递减参数。

(defn rain [days]
  (when (pos? days)
    (println (str "Rain: " days))
    (rain days)))   ;; 没有 (dec days),参数永远是 5

(rain 5)
;; Rain: 5
;; Rain: 5
;; Rain: 5
;; ...

结果番茄被严重过灌了。洪水就是这么来的。

这里有一个容易混淆的点:Clojure 提供了 recur~(尾递归优化),可以让递归不消耗栈空间。但 ~recur 解决的只是"不会撑爆栈"这一个问题。如果你忘了递减参数,它照样会无限循环——只不过不会因为栈溢出而崩溃,而是永远跑下去、占满 CPU:

(defn rain [days]
  (when (pos? days)
    (println (str "Rain: " days))
    (recur days)))   ;; 不消耗栈,但还是无限循环

recur 的好处是构造斐波那契海螺这种结构时不会撑爆栈,但如果终止条件(这里就是参数递减)写错了,~recur~ 也救不了你。

事故之后,高级时间特工被派去善后。他跟初级特工的区别在于——他精通 Clojure 的惰性序列(lazy sequence)。比如,一个只有两只鸡的向量:

[:hen :rooster]

cycle 可以把它变成一个无限长的惰性序列:

(cycle [:hen :rooster])
;; => (:hen :rooster :hen :rooster :hen :rooster ...)

不过高级特工没有把这个无限序列塞进取值器——否则就会创造另一起家禽泛滥的事故。他用 take 只取前面需要的数量:

(take 5 (cycle [:hen :rooster]))
;; => (:hen :rooster :hen :rooster :hen)

(take 10 (cycle [:hen :rooster]))
;; => (:hen :rooster :hen :rooster :hen :rooster :hen :rooster :hen :rooster)

惰性序列的精髓就在这里:你可以描述一个无穷大的东西,但只在需要的时候才去计算它。无限序列不会撑爆内存,因为 cycle 返回的是一个 promise(承诺)——你 take 多少,它就算多少。

事故之后,时间特工委员会决定:低级别递归需要靛蓝色等级(倒数第二高)才能使用,最高紫色等级只属于宏大师(Macro Masters)。其他所有人都被要求使用更安全的高级抽象: formapreduce

Frank 在腰包里翻找了一阵,最终掏出一把迷你棉花糖。

"找到了。走吧,有人要毁灭世界了,我们得去看看水獭。"

第三章:core.async 通道与水獭

Amy 和 Frank 冲下楼,发现门锁了。窗外,昨天的 Amy 正拿着笔记本电脑朝大门走来。

Frank 翻开《Clojure 搭车客指南》的"锁门及其他小麻烦"一章。指南推荐用 fnil 来解决锁门问题。 fnil 接受一个已有函数,返回一个新函数——当参数为 nil 时,用你指定的默认值代替:

(defn locked-door [key]
  (if key "open" "nope - staying shut"))

(locked-door :key)  ;; => "open"
(locked-door nil)   ;; => "nope - staying shut"

(def this-door (fnil locked-door :another-key-that-works))
(this-door :key)    ;; => "open"
(this-door nil)     ;; => "open"

门开了。两人开车来到动物园的水族馆。

水獭,Frank 解释说,是"前线时间守卫"。看过那些水獭用石头砸贝壳的自然视频吗?其实它们在求值 Clojure 表达式--以维持人类文明的正常运行。它们大多数时候喜欢远程工作(仰面漂浮在水面上,效率最高),有时会建造动物园或水族馆来近距离监控特定区域。

Frank 拿出四颗迷你棉花糖。给了 Amy 两颗,自己一颗塞进耳朵,一颗塞进嘴里,开始和水獭对话。

指南说,迷你棉花糖是创建便携式 Clojure core.async 通道的最佳材料——它不会在手里融化。

core.async 是 Clojure 的并发编程模型,灵感来自 Go 语言的 goroutine 和 channel。通道(channel)是 core.async 的核心概念——你可以把它想象成一根管子,一端放东西,另一端取东西。

core.async 有两套操作符:
- 两个叹号的 ~>!!~ / ~<!!~ 是阻塞操作: ~>!!~ 往通道放消息(put), ~<!!~ 从通道取消息(take)。两个叹号表示"我会一直等到操作完成",在此期间当前线程被卡住
- 一个叹号的 ~>!~ / ~<!~ 是非阻塞操作:功能一样,但只能在 ~go~ 块内使用。一个叹号表示"我不会阻塞线程", ~go~ 块会在等待时自动挂起当前逻辑、释放线程给别的任务用

chan 创建一个通道:

(def talk-to-otters-chan (chan))

默认情况下通道是无缓冲的(unbuffered),也就是棉花糖的原始大小。无缓冲通道要求发送方和接收方同时在场才能通信——就像面对面说话,你说一句,对方得听着,不然你就卡在那。

用阻塞操作 >!! 往通道放消息,会阻塞当前线程:

(>!! talk-to-otters-chan "Hello otters.")  ;; 线程会卡住,直到有接收方

所以一个办法是用 future 在另一个线程里发送:

(future (>!! talk-to-otters-chan "Hello otters."))
(<!! talk-to-otters-chan)  ;; => "Hello otters."

也可以用带缓冲的通道(相当于把棉花糖撑大一点):

(def talk-to-otters-chan (chan 10))  ;; 缓冲区大小为 10

(>!! talk-to-otters-chan "Hello otters.")
;; => nil(不阻塞,消息放进缓冲区了)

(>!! talk-to-otters-chan "Do you know anything about the world ending?")
;; => nil

(<!! talk-to-otters-chan)
;; => "Hello otters."

(<!! talk-to-otters-chan)
;; => "Do you know anything about the world ending?"

但最好的方式是用 go 块实现异步通信——不阻塞任何线程。在 go 块里用非阻塞操作 >! (一个叹号)和 <! (一个叹号):

(def talk-to-otters-chan (chan))

(go (while true
      (println (<! talk-to-otters-chan))))

(>!! talk-to-otters-chan "Hello otters")
(>!! talk-to-otters-chan "Do you know anything about the world ending?")
(>!! talk-to-otters-chan "Also, you are really fuzzy and cute.")

;; REPL 中的输出:
;; Hello otters
;; Do you know anything about the world ending?
;; Also, you are really fuzzy and cute.

更进一步的例子——双向对话。创建两个通道,一个负责说,一个负责听。~go~ 块之间通过通道自动转发消息:

(def talk-chan (chan))
(def listen-chan (chan))

(go (while true
      (println (<! listen-chan))))

(go (while true
      (>! listen-chan
          (str "You said: " (<! talk-chan) " Do you have any Abalone?"))))

(>!! talk-chan "Hello otters")
(>!! talk-chan "Do you know anything about the world ending?")
(>!! talk-chan "Also, you are really fuzzy and cute.")

;; REPL 中的输出:
;; You said: Hello otters Do you have any Abalone?
;; You said: Do you know anything about the world ending? Do you have any Abalone?
;; You said: Also, you are really fuzzy and cute. Do you have any Abalone?

水獭每次都问"你有鲍鱼吗?"——鲍鱼壳是水獭用来砸东西的工具,也是它们的薪水。

Amy 把一颗棉花糖塞进耳朵,立刻听到了 Frank 和水獭的对话。她伸手要把另一颗塞进嘴里问一个重要问题——但不小心碰到了求值器上那个大红 Source 按钮。她和 Frank 被一个漩涡卷起来,吸入地下。

故事戛然而止。Carin Meier 没有写第四篇。

用科幻故事教编程

原文的巧妙之处在于,每个科幻设定都精确对应一个 Clojure 技术概念:开心果是 s-expression,时间旅行是 Datomic 的事务模型,求值器是 REPL,大洪水是无终止条件的递归,棉花糖是 core.async 通道。概念之间不需要刻意安排过渡——故事本身就提供了自然的衔接。

这种写法适合 Clojure 有一个特殊原因:Lisp 的"代码即数据"哲学本身就很像科幻小说里的设定——"世界是由代码构成的,括号无处不在但你看不见"。当比喻和被比喻的对象在哲学层面一致时,读起来就不是生硬的类比,而是发现了两者之间的真实相似性。

Clojure : 函数式编程 : s-expression : 递归 : core.async