翻译自Growing a DSL with Clojure.主要讲解如何使用Clojure来创建一个简单的DSL.包括如下知识点:
Lisp及其方言(比如Clojure)可以很方便的创建DSL并能和源语言无缝的集成.
Lisp界鼓吹的优点中,提到最多的可能就是:数据即代码,代码即数据了。在此
文中我们将依此特性来定义一个DSL。
我们将渐进式的开发这个DSL,不断的加入Clojure的特性和抽象。
我们的目标是定义一个可以生成各种脚本语言的DSL.而且DSL代码看起来和普通
的Clojure代码没有区别。
例如,我们使用Clojure形式(form)来生成Bash脚本或者Windows批处理脚本:
输入(Clojure形式):
(if (= 1 2) (println "a") (println "b"))
输出(Bash脚本):
if [ 1 -eq 2 ]; then echo "a" else echo "b" fi
输出(Windows批处理):
IF 1==2 (
ECHO a
) ELSE (
ECHO b
)
我们先从Bash脚本开始。
在开始之前,我们先看看Clojure核心类型是否有什么类型我们可以直接拿到领域
语言中使用。在Clojure类型中是否有和Bash脚本类似的类型呢?
那就是字符串和基本类型,我们先从这里开始。
我们来定义一个emit-bash-form函数,它接受一个Clojure形式并返回一个符合
Bash脚本定义的字符串。
(defn emit-bash-form "Returns a String containing the equivalent Bash script to its argument." [a] (cond (= (class a) java.lang.String) a (= (class a) java.lang.Long) (str a) (= (class a) java.lang.Double) (str a) :else (throw (Exception. "Fell through"))))
cond表达式根据传入参数的类型来进行相应的操作。
user=> (emit-bash-form 1) "1" user=> (emit-bash-form "a") "a"
那么我们为什么要选择Long而不是Integer呢?因为在Clojure中,默认数据类型
是Long.
虽然Clojure支持Java所有的基本类型,但是默认情况下Clojure使用的是long和
double.Clojure会自动将int转成long,float转成double.可以简单的测试一下:
user=> (class 7)
java.lang.Long
现在,如果我们想添加条件判断,我们只需要在cond表达式中添加相应的分支即
可。
让我们继续添加功能。
Bash使用echo在屏幕上打印信息。如果你玩过Linux shell那么你应该对此不陌
生。
ambrose@ambrose-desktop> echo asdf asdf
clojure.core命名空间也包含了一个和Bash的echo类似功能的函数,叫println.
user=> (println "asdf") asdf ;=> nil
如果我们能直接将(println “a”)传递给emit-bash-form是不是很酷?
user=> (emit-bash-form (println "asdf")) asdf ;=> nil
那么首先,需要看看这是否可行.
我们使用Java来进行一下类比,假设我们要调用的是这样一段Java代码,它的第一个参数
类似于System.out.println(“asdf”).
foo(System.out.println("asdf"));
(我们先忽略System.out.println(…)返回的是void)
在Java中,参数会被先求值,然后再传递,也就是说,这里会先打印出asdf,然
后将println的返回值给foo方法。
我们如何能阻止参数被先求值呢?
很遗憾,在Java中这是不可能完成的任务。即使这在Java中可以实现,那后续我们能对
这段源代码做什么处理呢?
System.out.println(“asdf”)不是集合,所以我们不能遍历它;它也不是字符串,
我们也不能用正则表达式来切割它。不管System.out.println(“asdf”)是什么类
型,除了编译器,没人认识它。
Lisp则不会有这样的尴尬!
上节说到的Java的主要问题是没有能处理源代码的工具。Clojure是怎么解决这个问题的呢?
首先,为了能获得源码,Clojure提供了quote来阻止求值过程。
只需要在不需要求值的形式前面添加quote即可阻止该形式被求值。
user=> '(println "a") ;=> (println "a")
那么我们的返回值是什么类型呢?
user=> (class '(println "a")) ;=> clojure.lang.PersistentList
我们可以将返回值当成原始的Clojure列表(实际上它就是)
user=> (first '(println "a")) ;=> println user=> (second '(println "a")) ;=> "a"
这就是Lisp代码即数据所带来的一个好处.
使用了quote,我们就离DSL近了一步。
(emit-bash-form '(println "a"))
让我们将这个分支添加到emit-bash-form函数中。我们需要添加一个新的判断条
件。
但是这个分支该用什么类型来判断呢?
user=> (class '(println "a")) clojure.lang.PersistentList
所以让我们来添加一个clojure.lang.PersistentList判断分支.
(defn emit-bash-form [a] (cond (= (class a) clojure.lang.PersistentList) (case (name (first a)) "println" (str "echo " (second a))) (= (class a) java.lang.String) a (= (class a) java.lang.Long) (str a) (= (class a) java.lang.Double) (str a) :else (throw (Exception. "Fell through"))))
看看调用:
user=> (emit-bash-form '(println "a")) "echo a" user=> (emit-bash-form '(println "hello")) "echo hello"
我们有一个好的开始,现在在我们进行下一步前,先进行一下重构。
现在,我们要添加新的分支,那么就要在emit-bash-form函数中添加新的判断逻
辑。随着添加的分支越来越多,这个函数将越来越难维护了。我们需要将这个函
数切分成易于维护的片段.
emit-bash-form的调度是依据其参数的类型来进行的。而这可以通过Clojure的
多重方法来进行抽象。
我们来定义一个叫emit-bash的多重方法。
(defmulti emit-bash (fn [form] (class form))) (defmethod emit-bash clojure.lang.PersistentList [form] (case (name (first form)) "println" (str "echo " (second form)))) (defmethod emit-bash java.lang.String [form] form) (defmethod emit-bash java.lang.Long [form] (str form)) (defmethod emit-bash java.lang.Double [form] (str form))
多重方法的分派和cond很类似,但是不需要去写实际的分派代码。让我们来对比
一下多重方法和之前的代码。defmulti用来创建一个新的多重方法,并和分派函
数来关联。
(defmulti emit-bash (fn [form] (class form)))
defmethod用来添加具体的方法到多重方法中。在这里java.lang.String是指派
所依赖的值,而方法直接返回form自身.
(defmethod emit-bash java.lang.String [form] form)
添加新方法和扩展cond表达式的效果相同,差别就是:
多重方法来控制指派,不需要你去写控制代码。
那么我们该如何使用emit-bash呢?调用多重方法和调用普通的Clojure函数一模
一样:
user=> (emit-bash '(println "a")) "echo a"
分支判断由多重方法自己去判断了。
现在我们来实现Windows批处理.我们来定义一个新的多重方法,emit-batch:
(defmulti emit-batch (fn [form] (class form))) (defmethod emit-batch clojure.lang.PersistentList [form] (case (name (first form)) "println" (str "ECHO " (second form)) nil)) (defmethod emit-batch java.lang.String [form] form) (defmethod emit-batch java.lang.Long [form] (str form)) (defmethod emit-batch java.lang.Double [form] (str form))
现在我们能使用emit-batch和emit-bash了。
user=> (emit-batch '(println "a")) "ECHO a" user=> (emit-bash '(println "a")) "echo a"
比较一下两个实现,有很多相似的地方。实际上,只有
clojure.lang.PersistentList分支有区别。
我们想到了继承,Clojure可以很方便的实现继承。
当我说继承的时候,我可不是指依赖于类或者命名空间的那种继承,实际上继承
是一个与类或命名空间无关的独立功能。
但是像Java这样的语言,继承是绑定到了类层级上的.
我们能从一个名字派生到另一个名字,或者从类派生到名字。而这个名字可以是symbol
或者keyword.这样的话继承就更加的灵活和强大!
我们将使用(derive child parent)来定义父子关系。isa?来判断第一个参数是
不是派生自第二个参数。
user=> (derive ::child ::parent) nil user=> (isa? ::child ::parent) true
我们来定义Bash和Batch的继承关系
(derive ::bash ::common) (derive ::batch ::common)
测试一下
user=> (parents ::bash) ;=> #{:user/common} user=> (parents ::batch) ;=> #{:user/common}
现在我们可以利用继承关系来定义一个新的多重方法emit了。
(defmulti emit (fn [form] [*current-implementation* (class form)]))
这个函数返回了一个包含两个元素的vector。一个是当前的实现(::bash或
者::batch)和指派类型。*current-implementation*是个动态var,你可以把他看
做一个线程安全的全局变量。
(def ^{:dynamic true} "The current script language implementation to generate" *current-implementation*)
在我们的继承关系中,::common是父,这就意味着它需要提供公共方法。
需要记住的是,现在的指派值是个vector。所以在每个defmethod中,都需要包
含一个vector,其中第一个元素是指派值.
(defmethod emit [::common java.lang.String] [form] form) (defmethod emit [::common java.lang.Long] [form] (str form)) (defmethod emit [::common java.lang.Double] [form] (str form))
代码很类似。只有clojure.lang.PersistentList分支需要特别处理,其vector
的第一个元素需要为::bash或者::batch,而不能是::common了。
(defmethod emit [::bash clojure.lang.PersistentList] [form] (case (name (first form)) "println" (str "echo " (second form)) nil)) (defmethod emit [::batch clojure.lang.PersistentList] [form] (case (name (first form)) "println" (str "ECHO " (second form)) nil))
我们来测试一下
user=> (binding [*current-implementation* ::common] (emit "a")) "a" user=> (binding [*current-implementation* ::batch] (emit '(println "a"))) "ECHO a" user=> (binding [*current-implementation* ::bash] (emit '(println "a"))) "echo a" user=> (binding [*current-implementation* ::common] (emit '(println "a"))) #<CompilerException java.lang.IllegalArgumentException: No method in multimethod 'emit' for dispatch value: [:user/common clojure.lang.PersistentList] (REPL:31)>
因为我们没有定义[::common clojure.lang.PersistentList]的实现,多重方法
报错了。
多重方法非常强大且非常灵活,但是能力越强责任越大。我们可以将我们的多重
方法放在同一个命名空间下,但是不代表我们就需要这么做。当我们的DSL越来
越大的时候,我们需要将其分开到独立的命名空间下去。
这是个小例子,但是很好的展示了命名空间和继承的功能。
我们使用多重方法,动态var和ad-hoc继承创建了一个漂亮的,细粒度的DSL,但
是在使用的时候还是有些许的不便。
(binding [*current-implementation* ::bash] (emit '(println "a")))
我们来消除样板代码.但是它在哪呢?
binding表达式就是个样板代码,我们可以将binding的工作封装到
with-implementation中
(with-implementation ::bash (emit '(println "a")))
这是个改进。但是还有个改进没有这么的明显:用来延迟求值的quote。我们使用
script来消除这个quote.
(with-implementation ::bash (script (println "a")))
这样看起来好多了,但我们如何来实现script呢?Clojure函数会在求函数值前
对所有的参数进行求值,而quote就是用来解决这个问题。而现在我们要消除这
个quote。只能使用Lisp中的宏来处理。
宏不会去立即对参数求值,这正是我们需要的。
(defmacro script [form] `(emit '~form))
看看调用结果
(script (println "a")) => (emit '(println "a"))
比起欣赏宏美化语法的功能,记住宏的特性对你更有帮助。
对于with-implementation来说,也需要宏来解决,与script不同,它不是为了
延迟求值这个功能,而是对于其中的script来说,需要先将script的内容添加到binding
形式中,才能进行求值.
(defmacro with-implementation [impl & body] `(binding [*current-implementation* impl] ~@body))
好了,这就是DSL的所有内容了,实际上就添加了语法糖.
(with-implementation ::bash (script (println "a"))) => (with-implementation ::bash (emit '(println "a")) => (binding [*current-implementation* ::bash] (emit '(println "a")))
可以看出一个定义良好的宏如何来给代码添加语法糖.我们的DSL和普通的
Clojure代码看起来没啥区别.
在这个DSL中,我们看到了Clojure的很多高级特性.
我们来回顾一下我们构建DSL的过程.
一开始,我们使用了简单的cond表达式,然后变成了两个多重方法.接着我们使用
了继承和动态var来消除重复代码.最后我们使用宏来简化调用.
这个DSL是Stevedore的一个简化版本,Stevedore是Hugo Duncan开发的开源项目.如
果你对这个DSL的实现感兴趣,那么最好的方法就是去看Stevedore的源码了.
Copyright Ambrose Bonnaire-Sergeant, 2013
Translated By Ivan 2014.02