Clojure进阶:使用Clojure构建DSL
翻译自Growing a DSL with Clojure.主要讲解如何使用
Clojure来创建一个简单的DSL.包括如下知识点:
- 多重方法(Multimethods)
- 继承(Hierarchies)
- 元编程及"代码即数据"哲学(Metaprogramming and the "Code as data" philosophy)
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表达式中添加相应的分支即可。
Echo和Print
让我们继续添加功能。 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则不会有这样的尴尬!
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代码即数据所带来的一个好处.
细窥Clojure
使用了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"
分支判断由多重方法自己去判断了。
扩展我们的DSL实现Windows批处理
现在我们来实现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"
Ad-hoc继承
比较一下两个实现,有很多相似的地方。实际上,只有clojure.lang.PersistentList分支有区别。
我们想到了继承,Clojure可以很方便的实现继承。
当我说继承的时候,我可不是指依赖于类或者命名空间的那种继承,实际上继承是一个与类或命名空间无关的独立功能。
但是像Java这样的语言,继承是绑定到了类层级上的.
我们能从一个名字派生到另一个名字,或者从类派生到名字。而这个名字可以是symbol或者keyword.这样的话继承就更加的灵活和强大! 我们将使用(derive childparent)来定义父子关系。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
Copyright Ambrose Bonnaire-Sergeant, 2013 Translated By Ivan 2014.02