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代码成分。

enter image description here

图4-13 编译时元编程通过宏展开的方式产生代码。注意代码产生的时间是在编译阶段,因此没有任何额外的运行时开销,与图4-12的情况不同

具体的实现细节我们等一下再谈,现在先对下面将要涉及的问题域做一点分析。当客户委托中介交易(无论买入还是卖出)某一品种的票据时,将依次发生以下动作:

  • 中介向交易所提请交易;
  • 各中介按照委托内容完成中介间交易,引发成交;
  • 成交结果被分配到各客户账户,产生客户交易。

我们试着实现从成交结果产生客户交易的分配过程。在现实中,委托、成交结果、客户交易之间是多对多关系。为了让例子简单一些,我们假设成交结果与客户交易之间是一对一的关系。Clojure的鸭子类型将在这个用例的建模过程中贡献力量。

4.5 生成式DSL:通过宏进行编译时代码生成 - 图2 Clojure知识点

  • 前缀语法和函数式思维。Clojure的语法建立在“S表达式”的基础上,采用前缀表示法(prefix notation)。Clojure的语法非常规则,标榜绝对一致性,例如没有运算符优先级。Clojure是函数式语言,各种模块被组织成函数的形式。

  • Clojure的map数据结构普遍用于对象建模。

  • 可用于定义DSL的语法扩展。将宏在编译时生成代码的能力应用到DSL设计中,有利于使DSL语句简洁。

4.5.2 实现领域模型

我们来思考如何用Clojure定义交易与成交结果。交易和成交结果大致含有相同的信息,区别在于成交结果包含一个中介账户,而交易是在下单客户的客户账户上发生的。下面的代码片段分别给出了交易和成交结果的一个示例:

  1. (def tr1
  2. {:ref-no "tr-123"
  3. :account {:no "cl-a1" :name "john doe" :type ::trading} Clojure关键字 ::trading
  4. :instrument "eq-123" :value 1000})
  5. (def ex1
  6. {:ref-no "er-123"
  7. :account {:no "br-a1" :name "j p morgan" :type ::trading}
  8. :instrument "eq-123" :value 1000})

在交易的数据结构之内,有一个独立的账户数据结构。账户含有一个:type属性,表明它是交易账户还是结算账户。该账户类型属性被建模为Clojure关键字的形式➊,只起到一个符号标识的作用,求值后等于自身。Clojure关键字提供快速的相等性测试,被当做轻量级的常量字符串来使用。关于客户账户的概念及其不同类型,请阅读3.2.2节的补充内容“金融中介系统:客户账户”。

我们在代码清单4-14中定义两个函数:其一检查给定的账户是否为交易账户,其二分配成交结果到一个客户账户上,产生客户交易。后者是我们当前面对的主要问题域用例。我们先把函数定义出来,再思考哪些地方属于死板代码,可以通过宏来生成,让实现更简洁。

代码清单4-14 成交结果分配函数

  1. (defn trading?
  2. "若账户为交易账户,返回true "
  3. [account]
  4. (= (:type account) ::trading))
  5. (defn allocate
  6. "分配成交结果到客户账户并产生客户交易"
  7. [acc exe]
  8. (cond
  9. (nil? acc) (throw (IllegalArgumentException.
  10. "账户不可为空")) 验证
  11. (= (trading? acc) false) (throw (IllegalArgumentException.
  12. "必须为交易账户")) 验证
  13. :else {:ref-no (generate-trade-ref-no)
  14. :account acc
  15. :instrument (:instrument exe) :value (:value exe)}))

观察allocate函数的内容,它的核心业务逻辑位于cond语句的:else子句中。前面两个条件子句➊是交易子系统内任何操作都必须满足的验证。对于任何作用于交易的方法,我们都要确认交易账户是非空的实体,还要确认它确实是一个交易账户而非结算账户。以上内容构成了allocate方法的主要界面。allocate方法含有一些非本质的代码复杂性,有必要分解到核心的API实现之外。

4.5.3 Clojure宏之美

既然我们在allocate方法内做的那些验证其实广泛适用于所有的交易功能,何不把它们重构成可重用的实体呢?那样的话,我们将获得一个可以检查所有账户的验证函数,类似于先前4.4.1节定义Ruby模块TradeClassMethods时我们对trd_validate所做的处理。不过这次所用的手段——宏——有其鲜明的优点,请看插入内容。

enter image description hereClojure的宏特性可用于在编译阶段生成代码;编译后,宏被展开成普通的Clojure代码成分。优点有两重:

  1. 宏在编译阶段被内联展开,避免了函数调用的开销;

  2. 代码更具可读性,因为不需要用到lambda表达式。如果用高阶函数来实现的话,lambda表达式不可避免。

下面的例子可以让你看清楚宏的用法和特点。例中定义了一个宏,它用在语句里面形式上很像一般的Clojure控制抽象,但其实里面封装了验证逻辑。

  1. (defmacro with-account
  2. [acc & body]
  3. `(cond
  4. (nil? ~acc) (throw (IllegalArgumentException.
  5. "账户不可为空"))
  6. (= (trading? ~acc) false) (throw (IllegalArgumentException.
  7. "必须为交易账户"))
  8. :else ~@body))

注意,body内可含有不定数量的form,它们在非符号类型拼接(splicing unquote)运算~@的作用下被插入到生成的代码中。(关于非符号类型拼接的工作原理,详情请参阅4.7节参考文献[2]。)如果我们把验证逻辑实现成一个函数,不可避免地要用到lambda表达式。而当我们用with-account宏来定义allocate函数时,代码会十分清晰易懂:

  1. (defn allocate
  2. "分配成交结果到客户账户并产生客户交易"
  3. [acc exe]
  4. (with-account acc
  5. {:ref-no (generate-trade-ref-no)
  6. :account acc
  7. :instrument (:instrument exe) :value (:value exe)}))

现在,实现只需要重点关注核心的领域逻辑,与代码清单4-14相比简洁明了得多。全部的异常处理部分,还有全部的非本质复杂性,完全被分解出来放在宏里面,同时还没有增加任何运行时开销。with-account宏的功用不再局限于allocate的实现;它成了一个通用的控制结构,看上去和一般的Clojure语句成分没什么两样,而且可以被所有需要验证交易账户的API重用。

本节要点

本节的重点是使DSL实现简洁而又不失表现力。这个思路也贯穿了全章,各节在阐述过程中呈现的差别只在于如何实现这个思路。

Clojure宏除了可使DSL简洁,还对运行时性能没有任何影响。Clojure语言本身的可塑性非常强,允许你精确地表达DSL所需的语法和语义。Lisp家族这方面的能力非常突出,天生适合用于建模真实的世界。

不同形式的生成式DSL都可以代替你写代码。但借助敏锐的观察力,你肯定已经察觉不同语言实现,以及不同代码生成时机之间的差别。4.4节讨论了运行时代码生成,本节讨论如何通过Clojure宏实现编译时代码生成。两种策略各有优缺点,你必须仔细掂量所有的选项才好决定一个最佳的实现方案。