使用Clojure Thread Macro的心得

Fri 16 December 2011
  • 手艺 tags:
  • clojure
  • macro published: true comments: true

Thread Macro是clojure里一个很强大的宏,他帮助你简化嵌套函数的调用,比如
[cc lang="clojure"]
(str (inc (count [:a :b])))
[/cc]
就可以利用thread macro简写成
[cc lang="clojure"]
(-> [:a :b] count inc str)
[/cc]

->>和->类似,区别在于->>把值作为函数的最后一个参数传入。

简单的功能介绍完了,接下来就遇到问题了。我需要功能,能够接受一个或多个函数,然后把这些函数组成一个pipeline。这时很自然想到->是一个好帮手。也许只需要一个类似这样的form就可以了: #(apply -> % [funcs])。结果失败了,因为->是个宏,所以根本不能用apply。于是想到有apply-macro吗?有,或者说曾经有过。在contrib中曾经有一个apply-macro,不过被强烈不推荐使用。到这里,这条路堵死了,惟一的办法就是把->放到API之外,放到用户代码里去。

放到用户代码里,你需要写一个详细的说明文档并且告诉用户他必须这么做。然而在clojure世界里有一个更好的办法就是再写一个宏把->包装起来。这么做看似多此一举,其实是保持了API的一致性。通过宏,我们可以把自己的API延伸到用户代码中去。或者说,通过一个类似DSL的宏,给一些并不优雅的API一个缓冲,也为API日后的演化留下空间。

这里还要扯开一句关于宏的开发。clojure中所谓code is data,主要就是体现在宏里。原本在多数其他语言里,宏是不能求值的。但是在clojure里,由于code is data的缘故,宏是可以求值的。所有的输入数据都是list,你可以做first/reverse这样的操作。但是有一点要注意的是,宏中求得的值和代码里的值是不一样的。例如{:a inc}这样一个字面量,在宏里是可以通过:a做求值的,然后这里得到的并非一个函数(function),而是一个符号(symbol)。再者,调试宏的时候你可能会被这样的结果困惑:
[cc lang="clojure"]
(defmacro a [f] (println (:a f)))
(a {:a 1}) ; ==> prints 1
(def b {:a 1})
(a b) ; ==> prints nil
[/cc]
字面量可以,同值的变量就不行了。原因还是那句,宏里不能求值。

继续谈->。这个宏其实远没有你想象的那么驯服。遇到复杂一点的情况:
[cc lang="clojure"]
(def m {:a inc})
(-> 2 (get m :a) str)
[/cc]
这个写法对吗?str是个函数,(get m :a)返回的是inc也是个函数,貌似正确。运行之后却报错get的参数数量错误。所以千万不要忘了->是个宏,(get m :a)这里是不会求值到inc的,直接作为一个list被宏吞下去。在宏里只能通过符号的组合变化来生成代码,那么一不小心,就没有inc什么事了。

于是,你可能想到这里需要一个确切的函数,就好比这样:
[cc lang="clojure"]
(def m {:a inc})
(-> 2 (fn [x] ((get m :a) x)))
[/cc]
也许这样就好多了,我们放了一个匿名函数,并不要求宏去求值,因为这个匿名函数会被宏生成到新的代码里。里面的get也会在运行时求值。看似没什么问题,可是一运行还是没有期待的结果,居然返回了一个匿名函数!而对这个匿名函数求值得到的也是一个错误的结果!简直有点无厘头了。

呵呵,不故弄玄虚了。我们用macroexpand看看发生了什么。

这是用匿名函数包装以前
[cc lang="clojure"]
(macroexpand-1 '(-> 2 (get m :a)))
(get 2 m :a)
[/cc]
->居然只是简单地把2放到了get这个form里面!

再看看用匿名函数包装后的结果
[cc lang="clojure"]
(macroexpand-1 '(-> 2 (fn [x] ((get m :a) x))))
(fn 2 [x] ((get m :a) x))
[/cc]
和刚才一样,2被放到了第一个form的第一个参数位置!得到的是一个非法的form。

那么既然->只是简单地把第一个参数放到后面form的首个参数的位置,那么这个宏正确的使用方法其实是
[cc lang="clojure"]
(def m {:a inc})
(-> 2 ((fn [x] ((get m :a) x))))
[/cc]
再加一层括号!
[cc lang="clojure"]
(macroexpand-1 '(-> 2 ((fn [x] ((get m :a) x)))))
((fn [x] ((get m :a) x)) 2)
[/cc]

可见,->虽然是个功能强大的宏,但宏终归只是宏,和函数的区别是明显的。在使用的时候,不能完全按照函数的习惯。

如果你想了解实际的代码,可以参考slacker 0.3.0里的这个interceptor框架: https://github.com/sunng87/slacker/blob/master/src/slacker/interceptor.clj 上面提到的难处,多半也都是在开发这个框架时亲身经历的。