核心库(core.clj)的实现代表了 Clojure 的标准和规范。通过阅读核心库的代码可以提高自身对 Clojure 的理解,并且帮助自己从 C 语系的命令式(imperative)的思想转变到非命令式。
核心库的函数实现通常不超过 10 行,以五行左右居多。它的简洁性来源于对递归的深入使用。同时,也依赖于 if, cons, conj, seq, next 对 nil 的特殊处理。
Clojure 中尽管有布尔型,但是不像其它的高级语言一样强制要求 if 接受布尔型。而是像 C/C++ 一样将 nil 作为 false 来看待。所以像 JavaScript 一样,经常会看到这样的代码:
(if x
then ; x 存在
else) ; x 不存在
cons 用来连接元素和集合。但是,如果 cons 的第二个参数,也就是集合,如果为 nil 的话,cons 会把这个集合当作空集来看待。比如:
(cons 1 []) ==> (1)
(cons 1 nil) ==> (1)
conj 类似,只不过 conj 是第一个参数接集合。如果集合为 nil,也被当作空集。(当然 cons 和 conj 作用并不完全一样)seq 和 next 的特殊性是这样:
(seq []) ==> nil
(next []) ==> nil
Clojure 标准库中各种函数对 nil 的宽容对待都是基于这几个基本函数做到的。比如下面是经过简化的 reduce 实现:
(defn reduce
([f val coll]
(if-let [s (seq coll)]
(recur f (f val (first s)) (next s))
val)))
if-let 只有在 coll 不为空集时才会隐式地用 let 绑定 s。再比如 conj 的实现:
(def
conj (fn conj
([coll x] (. clojure.lang.RT (conj coll x)))
([coll x & xs]
(if xs
(recur (conj coll x) (first xs) (next xs))
(conj coll x)))))
注意第二个函数重载,当函数递归到最后一层的时候因为 next 调用的原因, xs 必为 nil。这时候 (if xs) 就会是 false,函数停止递归。
核心库中大部分都是这样的短小的函数。但是因为性能优化的需要,函数可能会比你现在看到的要臃肿一些。比如 reduce 在 1.1.0 版本中的实际实现:
(defn reduce
([f coll]
(let [s (seq coll)]
(if s
(reduce f (first s) (next s))
(f))))
([f val coll]
(let [s (seq coll)]
(if s
(if (chunked-seq? s)
(recur f
(.reduce (chunk-first s) f val)
(chunk-next s))
(recur f (f val (first s)) (next s)))
val))))
除去第一个重载给用户提供了便利之外,第二个重载中使用了 chunked-seq。chunked-seq 就是性能优化的产物。看核心库代码的时候如果感觉函数有点长,不好懂,那就先尝试把这些为了性能优化而加的代码去掉。可以在心里去掉,也可以把代码拷贝出来,从视觉上去掉。
目前我看到的最难懂的代码就是 doseq 和 for 的实现,达到了 30 - 80 行。我的智商不高,看 doseq 花了很长时间。不过也因此得出一条重要的经验:看复杂代码的时候要先揣摩作者的意图,以意图作为切入点,以具体代码逻辑作验证,看看自己揣摩出来的意图是不是跟作者一致。作为一名传统的 C 语系的程序员,对 FP 风格的代码不熟的确给我的阅读带来了一定障碍。但是我保证,如果你跟我一样坚持下来,你会发现 Clojure 在微观级别的抽象表达能力比 C 语系的命令式语言要强上一个档次。这点对于你理解算法来说影响非常大。直观上觉得同样的程序 Java 写出来更好懂。但如果你试着去熟悉 Clojure 会发现事实刚好相反。看 doseq 的实现:
(defmacro doseq
"Repeatedly executes body (presumably for side-effects) with
bindings and filtering as provided by \"for\". Does not retain
the head of the sequence. Returns nil."
[seq-exprs & body]
(assert-args doseq
(vector? seq-exprs) "a vector for its binding"
(even? (count seq-exprs)) "an even number of forms in binding vector")
(let [step (fn step [recform exprs]
(if-not exprs
[true `(do ~@body)]
(let [k (first exprs)
v (second exprs)]
(if (keyword? k)
(let [steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)]
(cond
(= k :let) [needrec `(let ~v ~subform)]
(= k :while) [false `(when ~v
~subform
~@(when needrec [recform]))]
(= k :when) [false `(if ~v
(do
~subform
~@(when needrec [recform]))
~recform)]))
(let [seq- (gensym "seq_")
chunk- (with-meta (gensym "chunk_")
{:tag 'clojure.lang.IChunk})
count- (gensym "count_")
i- (gensym "i_")
recform `(recur (next ~seq-) nil (int 0) (int 0))
steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)
recform-chunk
`(recur ~seq- ~chunk- ~count- (unchecked-inc ~i-))
steppair-chunk (step recform-chunk (nnext exprs))
subform-chunk (steppair-chunk 1)]
[true
`(loop [~seq- (seq ~v), ~chunk- nil,
~count- (int 0), ~i- (int 0)]
(if (< ~i- ~count-)
(let [~k (.nth ~chunk- ~i-)]
~subform-chunk
~@(when needrec [recform-chunk]))
(when-let [~seq- (seq ~seq-)]
(if (chunked-seq? ~seq-)
(let [c# (chunk-first ~seq-)]
(recur (chunk-rest ~seq-) c#
(int (count c#)) (int 0)))
(let [~k (first ~seq-)]
~subform
~@(when needrec [recform]))))))])))))]
(nth (step nil (seq seq-exprs)) 1)))
首先不去看有关 chunked-seq 的部分,在这么复杂的函数(而且还是宏!)中它会极大地干扰你的阅读。随便一扫就可以发现函数的主体都在 step 这个内部函数里面。递归包含两部分:1、递推基础 2、递推规则。标准库很多函数都会定义一个内部函数 step。它就承担着定义递推规则的责任。所以 doseq 里面只有一句话 (nth (step nil (seq seq-exprs)) 1))),它只负责提供递推基础。
step 的第二个参数 exprs 从名字和调用上就可以看出是绑定表达式串(使用 doseq 时的中括号的部分)。recform 从名字上不好猜是什么,搜索一下 recform,发现这样一句:
recform `(recur (next ~seq-) nil (int 0) (int 0))
可以看出 recform 就是 recur-form 的意思。注意如果你跟我一样是严谨逻辑派的人,你会发现这样的假设并不严谨,不过不重要,我们目前的首要目标是猜出作者的意图,最后再用严谨的逻辑验证一次就好了。当然,这个过程可能因为猜测错误而反复多次。目前我们知道 recform 应该是用来承载 recur 的内容。那么相应地,needrec 也就应该表示是否需要继续递归的意思。通过这句话
(let [steppair (step recform (nnext exprs))
可以看出 steppair 表示 step 的返回值,它应该是一个大小为二的向量。再结合
(let [steppair (step recform (nnext exprs))
needrec (steppair 0)
subform (steppair 1)]
可以看出这个向量的第一个元素表示是否需要继续递归,而第二元素表示 step 递归缩减出来的子 form。有了上面这些猜测,我们可以看出 step 的最后三行是核心。
(let [~k (first ~seq-)]
~subform
~@(when needrec [recform]))))))])))))]
它们表示局部绑定 doseq 中指定集合的第一个元素,然后递归展开子 form。通过
(= k :let) [needrec `(let ~v ~subform)]
(= k :while) [false `(when ~v
~subform
~@(when needrec [recform]))]
(= k :when) [false `(if ~v
(do
~subform
~@(when needrec [recform]))
~recform)]))
可以看出 doseq 支持 for 所支持的三个额外参数 let, when, while。当这些都理解的时候就可以回过头去看 chunked-seq 的部分了。