ivaneye.com

Clojure进阶:使用Clojure构建DSL

翻译自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表达式中添加相应的分支即可。

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