4.5 生成式DSL:通过宏进行编译时代码生成
歇好了吧?那我们就开始吧。Ruby和Groovy的生成式元编程在运行时产生代码,使DSL表面语法保持紧凑,让语言运行时代替你编写那些死板代码。而对于Clojure(JVM上的Lisp),一方面你会获得所有代码生成方面的好处,另一方面它又没有Groovy和Ruby那种运行时负担。(关于Clojure语言的详情,请访问http://clojure.org。)Clojure语言按照其创造者Rich Hickey的设计,拥有Lisp语言的语法和语义,同时又能无缝地集成Java的对象系统。关于这种语言及其运行时的详情,请参阅4.7节参考文献[3]。
4.5.1 开展Clojure元编程
Clojure是一种动态类型语言,实现了“鸭子类型”,还提供强大的函数式编程能力。本节我们重点讨论它通过“宏”机制提供的代码生成能力。
Clojure通过宏来进行的代码生成属于一种编译时元编程,我们在2.3.1节提到过。如果你不熟悉Lisp或Clojure编译时宏的工作原理和基本概念,请阅读附录B。图4-13简要概括了编译时元编程系统的事件流程。开发者在程序中以宏的形式定义高阶抽象,然后高阶抽象在编译阶段被展开成正式的Clojure代码成分。
图4-13 编译时元编程通过宏展开的方式产生代码。注意代码产生的时间是在编译阶段,因此没有任何额外的运行时开销,与图4-12的情况不同
具体的实现细节我们等一下再谈,现在先对下面将要涉及的问题域做一点分析。当客户委托中介交易(无论买入还是卖出)某一品种的票据时,将依次发生以下动作:
- 中介向交易所提请交易;
- 各中介按照委托内容完成中介间交易,引发成交;
- 成交结果被分配到各客户账户,产生客户交易。
我们试着实现从成交结果产生客户交易的分配过程。在现实中,委托、成交结果、客户交易之间是多对多关系。为了让例子简单一些,我们假设成交结果与客户交易之间是一对一的关系。Clojure的鸭子类型将在这个用例的建模过程中贡献力量。
Clojure知识点
前缀语法和函数式思维。Clojure的语法建立在“S表达式”的基础上,采用前缀表示法(prefix notation)。Clojure的语法非常规则,标榜绝对一致性,例如没有运算符优先级。Clojure是函数式语言,各种模块被组织成函数的形式。
Clojure的map数据结构普遍用于对象建模。
宏可用于定义DSL的语法扩展。将宏在编译时生成代码的能力应用到DSL设计中,有利于使DSL语句简洁。
4.5.2 实现领域模型
我们来思考如何用Clojure定义交易与成交结果。交易和成交结果大致含有相同的信息,区别在于成交结果包含一个中介账户,而交易是在下单客户的客户账户上发生的。下面的代码片段分别给出了交易和成交结果的一个示例:
(def tr1
{:ref-no "tr-123"
:account {:no "cl-a1" :name "john doe" :type ::trading} ➊ Clojure关键字 ::trading
:instrument "eq-123" :value 1000})
(def ex1
{:ref-no "er-123"
:account {:no "br-a1" :name "j p morgan" :type ::trading}
:instrument "eq-123" :value 1000})
在交易的数据结构之内,有一个独立的账户数据结构。账户含有一个:type
属性,表明它是交易账户还是结算账户。该账户类型属性被建模为Clojure关键字的形式➊,只起到一个符号标识的作用,求值后等于自身。Clojure关键字提供快速的相等性测试,被当做轻量级的常量字符串来使用。关于客户账户的概念及其不同类型,请阅读3.2.2节的补充内容“金融中介系统:客户账户”。
我们在代码清单4-14中定义两个函数:其一检查给定的账户是否为交易账户,其二分配成交结果到一个客户账户上,产生客户交易。后者是我们当前面对的主要问题域用例。我们先把函数定义出来,再思考哪些地方属于死板代码,可以通过宏来生成,让实现更简洁。
代码清单4-14 成交结果分配函数
(defn trading?
"若账户为交易账户,返回true "
[account]
(= (:type account) ::trading))
(defn allocate
"分配成交结果到客户账户并产生客户交易"
[acc exe]
(cond
(nil? acc) (throw (IllegalArgumentException.
"账户不可为空")) ➊ 验证
(= (trading? acc) false) (throw (IllegalArgumentException.
"必须为交易账户")) ➊ 验证
:else {:ref-no (generate-trade-ref-no)
:account acc
:instrument (:instrument exe) :value (:value exe)}))
观察allocate
函数的内容,它的核心业务逻辑位于cond
语句的:else
子句中。前面两个条件子句➊是交易子系统内任何操作都必须满足的验证。对于任何作用于交易的方法,我们都要确认交易账户是非空的实体,还要确认它确实是一个交易账户而非结算账户。以上内容构成了allocate
方法的主要界面。allocate
方法含有一些非本质的代码复杂性,有必要分解到核心的API实现之外。
4.5.3 Clojure宏之美
既然我们在allocate
方法内做的那些验证其实广泛适用于所有的交易功能,何不把它们重构成可重用的实体呢?那样的话,我们将获得一个可以检查所有账户的验证函数,类似于先前4.4.1节定义Ruby模块TradeClassMethods
时我们对trd_validate
所做的处理。不过这次所用的手段——宏——有其鲜明的优点,请看插入内容。
Clojure的宏特性可用于在编译阶段生成代码;编译后,宏被展开成普通的Clojure代码成分。优点有两重:
宏在编译阶段被内联展开,避免了函数调用的开销;
代码更具可读性,因为不需要用到lambda表达式。如果用高阶函数来实现的话,lambda表达式不可避免。
下面的例子可以让你看清楚宏的用法和特点。例中定义了一个宏,它用在语句里面形式上很像一般的Clojure控制抽象,但其实里面封装了验证逻辑。
(defmacro with-account
[acc & body]
`(cond
(nil? ~acc) (throw (IllegalArgumentException.
"账户不可为空"))
(= (trading? ~acc) false) (throw (IllegalArgumentException.
"必须为交易账户"))
:else ~@body))
注意,body
内可含有不定数量的form,它们在非符号类型拼接(splicing unquote)运算~@
的作用下被插入到生成的代码中。(关于非符号类型拼接的工作原理,详情请参阅4.7节参考文献[2]。)如果我们把验证逻辑实现成一个函数,不可避免地要用到lambda表达式。而当我们用with-account
宏来定义allocate
函数时,代码会十分清晰易懂:
(defn allocate
"分配成交结果到客户账户并产生客户交易"
[acc exe]
(with-account acc
{:ref-no (generate-trade-ref-no)
:account acc
:instrument (:instrument exe) :value (:value exe)}))
现在,实现只需要重点关注核心的领域逻辑,与代码清单4-14相比简洁明了得多。全部的异常处理部分,还有全部的非本质复杂性,完全被分解出来放在宏里面,同时还没有增加任何运行时开销。with-account
宏的功用不再局限于allocate
的实现;它成了一个通用的控制结构,看上去和一般的Clojure语句成分没什么两样,而且可以被所有需要验证交易账户的API重用。
本节要点
本节的重点是使DSL实现简洁而又不失表现力。这个思路也贯穿了全章,各节在阐述过程中呈现的差别只在于如何实现这个思路。
Clojure宏除了可使DSL简洁,还对运行时性能没有任何影响。Clojure语言本身的可塑性非常强,允许你精确地表达DSL所需的语法和语义。Lisp家族这方面的能力非常突出,天生适合用于建模真实的世界。
不同形式的生成式DSL都可以代替你写代码。但借助敏锐的观察力,你肯定已经察觉不同语言实现,以及不同代码生成时机之间的差别。4.4节讨论了运行时代码生成,本节讨论如何通过Clojure宏实现编译时代码生成。两种策略各有优缺点,你必须仔细掂量所有的选项才好决定一个最佳的实现方案。