2010年5月30日星期日

Clojure 的类型提示

为了提高与 Java 类库交互时的性能,Clojure 提供了元数据用以定义某个给定的变量的类型。比如在函数定义的时候可以指定参数与返回值的类型:

(defn add-elem [#^java.util.List java-list elem]
(.add java-list elem)
java-list)

等效于

(defn add-elem [#^{:tag java.util.List} java-list elem]
(.add java-list elem)
java-list)

如果不告诉编译器这个类型

(defn naive-add-elem [java-list elem]
(.add java-list elem)
java-list)

Clojure 一样可以顺利执行,只不过 Clojure 是通过反射去调用 add 方法的。因为编译器没办法知道运行时的参数可能会是什么类型。所以,两种方法的执行时间差距还是有点大的:

(def java-list (java.util.ArrayList.))

(time (dotimes [_ 100000]
(naive-add-elem java-list 1))) ==> "Elapsed time: 342.663873 msecs"

(time (dotimes [_ 100000]
(add-elem java-list 1))) ==> "Elapsed time: 18.652259 msecs"

有一个数量级的差距,跟反射与普通方法调用的差距差不多。但是不带类型提示的版本会在重用性上取胜。比如:

(def java-list (java.util.ArrayList.))
(def java-set (java.util.HashSet.))

(naive-add-elem java-list 1) ==> #<ArrayList [1]>
(naive-add-elem java-set 1) ==> #<Hashset [1]>

(add-elem java-list 1) ==> #<ArrayList [1]>
(add-elem java-set 1) ==> ClassCastException!

与类型提示有点关系的就是 :inline。为了加快 Clojure 在 JVM 上的数字运算,Clojure 提供了内联。因为 Clojure 与 C/C++ 一样具有宏的功能(只不过强大了太多),所以 Clojure 也可以做到源代码展开。比如 num 的实现:

(defn num
{:tag Number
:inline (fn [x] `(. clojure.lang.Numbers (num ~x)))}
[x] (. clojure.lang.Numbers (num x)))

编译器何时会选择内联我不知道,只是它的确有这种优化。因为在频繁数字运算时 HotSpot 会将包装类型优化成原始类型,所以 Clojure 依赖 JVM 的这个特性,通过内联将优化的任务交给 JVM 去做。Clojure 的官网说下面的两段代码在 JVM 的 -server 属性开启时运行速度一样:

static public float asum(float[] xs) {
float ret = 0;
for (int i = 0; i < xs.length; i++)
ret += xs[i];
return ret;
}

(defn asum [#^floats xs]
(areduce xs i ret (float 0)
(+ ret (aget xs i))))

Clojure 在给数字运算型函数做内联的时候与普通宏不一样的地方是,内联的宏都返回一个函数。这样它们就可以被扔到 map, reduce 这样的函数去执行了。对于原始类型数组,Clojure 特别添加了支持:#^ints, #^floats, #^longs, #^doubles。关于内联具体请参看:http://clojure.org/news

对于那些无法在函数声明时在参数列表就加上类型提示的,可以在 Java 调用的时候再指明参数类型。比如:

(defn bigint
{:tag BigInteger}
[x] (cond
(instance? BigInteger x) x
(decimal? x) (.toBigInteger #^BigDecimal x)
(number? x) (BigInteger/valueOf (long x))
:else (BigInteger. x)))

这个函数在声明参数 x 的时候并没有指明类型,但是通过 decimal? 测试后 x 就应该是 BigDecimal 类型的了,这个时候就可以告诉编译 x 的类型是 BigDecimal。

2010年5月29日星期六

Clojure agents

Clojure agents 是用 JDK 的线程池实现的。比如 send

(defn send
[#^clojure.lang.Agent a f & args]
(. a (dispatch f args false)))

clojure.lang.Agent#dispatch 方法把 f 封装成一个 Action 对象,再把执行这个 Action 的任务代理给 Action 自身的 execute 方法。execute 方法会把自身的 action 实例扔到线程池中去执行。看看 send 的兄弟,send-off

(defn send-off
[#^clojure.lang.Agent a f & args]
(. a (dispatch f args true)))

注意到了吗?这两个函数只差一个参数,send 在调用 dispatch 的时候最后一个参数是 false,而 send-off 是 true。这个参数为真表示 Action 在执行的时候是使用 CachedThreadPool,否则使用 FixedThreadPool 。FixedThreadPool 是大小固定的线程池,所以 send 方法的 f 函数不能包含 IO 操作,而 send-off 可以。

相应地,agent 在执行时线程池会被初始化。所以,如果你的程序中使用到了 agents,在程序的最后要记得关闭线程池。如果你不关闭,在 eclipse 或者 IDEA 中你会看到程序执行完毕后标准输出窗口的结束程序的按钮仍然可用。原因就是线程池未关闭。当然关闭线程池最重要的作用是确保线程池中所有的操作都已完成,然后释放资源。结束 agents 系统,关闭线程池的函数是 shutdown-agents,不带任何参数。

2010年5月28日星期五

Clojure 核心库代码阅读技巧

核心库(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 的部分了。

2010年5月25日星期二

精巧的状态机实现


public enum SniperState {
JOINING {
@Override public SniperState whenAuctionClosed() { return LOST; }
},
BIDDING {
@Override public SniperState whenAuctionClosed() { return LOST; }
},
WINNING {
@Override public SniperState whenAuctionClosed() { return WON; }
},
LOST,
WON;
public SniperState whenAuctionClosed() {
throw new Defect("Auction is already closed");
}
}

Sniper 英语是狙击手的意思,在这个程序的上下文中表示拍卖交易中的自动出价器。它的状态包括刚刚加入拍卖交易过程、正在出价但未占上风、正在出价但暂时领先、已经失败、已经成功拍下。whenAuctionClosed() 是一个事件回调,表示拍卖交易已经关闭时的回调。

因为已经失败和成功拍下这两个状态都是最终状态,所以调用 whenAuctionClosed() 会抛异常。而其它几个状态会重载这个方法,返回交易关闭后对应的状态。

这段代码摘自《Growing Object-Oriented Software, Guided by Tests》。