5.4 思路迥异的Clojure实现

本节将为你展示如何在Clojure语言中实现计算交易的现金价值。(假如记不清交易的现金价值是如何定义的,请翻阅第4.2.2小节的插入栏。)我们照例采用基于DSL的实现方式,自底向上先建立若干小的领域抽象,然后利用Clojure的组合子把它们组合起来。

同样的用例已经在第5.2.4小节中用Ruby语言实现过一遍,那我们为什么要重复呢?因为Ruby语言的编程范式与Clojure完全不一样。Ruby是一种面向对象语言,运行时元编程是其主要的DSL实现手段。Clojure基本是一种函数式语言,它的宏特性具有很强的编译时元编程能力。显然Clojure的实现思路会与Ruby或Groovy有很大差异。即使用例相同,基于Clojure的DSL和基于Ruby的DSL实现起来完全可能是两回事。所以,我们在此特意选择与Ruby范例相同的用例,向你说明选择不同宿主语言对设计决策的影响。表5-3列举了Clojure相对于Ruby的关键差异点。对Clojure语言本身的详细介绍,请参阅第5.7节文献[6]。

表5-3 用Clojure语言实现DSL的时候需要改变思维方向

Ruby语言的DSL实现 Clojure语言的DSL实现
以对象和模块为思考对象,考虑如何通过元编程把运行时的对象和模块组合在一起 以用例中的函数为思考对象,考虑如何通过Clojure序列(sequences)的lambda操作来组织各函数
运用method_missingconst_missing等技巧,以及其他动态元编程特性使DSL简洁有表现力 运用宏将DSL语法转换为一般的Clojure语法成分,转换完全在编译时完成
Ruby或Groovy语言实现的DSL,其语法风格不一定接近于宿主语言 Clojure语言实现的DSL,因为仍然以“S表达式”为结构基础,其语法风格接近于Clojure

我强烈建议你先把前面的Ruby实现温习一遍,再跟着我一起学习下面的Clojure实现,这样你会对两者的差异有更深的体会。

5.4.1 建立领域对象

构建DSL先要有一些基本抽象作为核心的领域模型。那么,我们第一步先设计好“交易”抽象,并且给它定义一个工厂方法(见代码清单5-13),然后根据传入的信息生成交易对象。

定义 工厂方法是一种设计模式,它为一组相似对象的实例创建操作提供单一的交互入口。

5.4 思路迥异的Clojure实现 - 图1Clojure知识点

  • 基本的函数定义和语法。Clojure的语法接近于Lisp,陌生的前缀表示法可能让你措手不及。如果不习惯Clojure的语法,请细读一下第5.7节文献[4]的基础知识。

  • 定义Map数据结构。Clojure编程中常用Map数据结构来仿造“类”这种OO编程结构。

属性可以来自Web请求、文本文件、数据库,系统连接到的任何数据源都可以作为交易对象的信息来源。工厂方法从请求中提取信息,并建立一个map来表示一笔交易的全部属性。

代码清单5-13 生成交易的Clojure代码

  1. (defn trade
  2. "根据请求产生一笔交易"
  3. [request]
  4. {:ref-no (:ref-no request) 从请求构建交易
  5. :account (:account request)
  6. :instrument (:instrument request)
  7. :principal (* (:unit-price request) (:quantity request)) 基本价值 = 单价 * 数量
  8. :tax-fees {}}) 税费的具体内容稍后再填入
  9. (def request 请求样例
  10. {:ref-no "trd-123"
  11. :account "nomura-123"
  12. :instrument "IBM"
  13. :unit-price 120
  14. :quantity 300})

Clojure在对象系统之上向用户呈现了一个函数式的编程模型。例中我们将抽象实现为一系列键-值对,也就是Clojure Map的形式。其中trade是一个函数,负责根据从request输入的相关信息建立必要的抽象。作为输入的request➌也是一个Map,被实现为各键的函数。当我们从Map中提取信息的时候,所用语法与一般的函数调用相同➊。例如字面量语句(:account request)意为从Map中取出account键的值。

trade方法清晰地表达了领域意图和领域语义。Clojure的map字面量语法起到了命名参数的效果,领域概念可以直接被映射为编程元素,从而提高了代码的表现力。tax-fees这个map目前只是一个占位符➋,下一节对生成的交易进行后续充实操作时再填入具体的内容。

5.4.2 通过装饰器充实领域对象

下一步要对基本抽象进行补充,使之适用于交易周期中的具体用例。新的特性以装饰器的方式附加到基本抽象之上,我们在第5.2.4小节就通过同样的模式在Ruby实现中加入税费组件。不过这一次的实现手段是Clojure语言的编译时元编程和宏。

enter image description hereDSL的设计工作要将目标语法映射到语言背后的语义。那么当实现语言变了,思维方式也应该随之改变。

5.4 思路迥异的Clojure实现 - 图3Clojure知识点

  • 高阶函数。Clojure支持高阶函数特性,函数完全可以像值一样使用。函数可以作为参数来传递,也可以充当返回值,诸如此类。

  • 是用Clojure语言开发DSL的根本秘诀。宏是编译时元编程的基本组织元素。

  • “Let绑定”和词法作用域。不管作用域有多小,你都可以根据需要精确指定绑定的作用域。

  • 掌握Clojure标准库函数。Clojure网站(http://clojure.org)上有丰富的相关资源。

  • 不可变数据结构。Clojure提供不可变、持久化(persistent)的数据结构。这里的“持久化”指即使改动了数据结构之后,用户仍可以访问所有旧版本的数据。详见第5.7节文献[4]。

  • 若干基本的组合子,如reduce->。组合子是以其他函数作为参数的函数,能帮你写出简洁而有表现力的代码。

怎么样才能动态地给抽象增加新行为,却不增加运行时的性能负担呢?Clojure给出的答案是编译时mixin。我们来看看具体的做法。

1.使用Clojure组合子

假设我们有一个with-tax-fee结构,它的作用是在现有的Clojure函数上引入新的行为,从而给我们的交易加上税费。在下面的代码片段中,当with-tax-fee作用于trade函数时,我们会得到一个新的trade函数,新函数在原有属性集合上增加了:tax:commission两则映射。

  1. (with-tax-fee trade
  2. (with-values :tax 12)
  3. (with-values :commission 23))

在这里,with-tax-fee充当了trade函数的装饰器。现在当你根据request执行trade函数时,其中的税金和佣金项目将分别被设置为基本价值的12%和23%。(税金和佣金一般以交易基本价值的百分比来计算。)

除了DSL的实现者,别的人一般不需要关心with-tax-feewith-values等语言构造的具体实现,把它们当做一般的组合子用于交易DSL的抽象设计即可。不过本节既然讨论DSL的实现,自然应该探讨,什么样的函数才能充当另一个函数的装饰器,为其补充新的行为。下面是with-values的实现。

代码清单5-14 给trade抽象包裹一层新行为

  1. (defn with-values [trade tax-fee value] 高阶函数
  2. (fn [request] 返回一个函数
  3. (let [trdval (trade request) 获取交易价值
  4. principal (:principal trdval)]
  5. (assoc-in trdval [:tax-fees tax-fee]
  6. (* principal (/ value 100)))))) 以基本价值的百分比的形式存入:tax-fees Map

with-values组合子对trade函数的输出做了不少补充工作。虽说本书的主题不是介绍Clojure语言,但对于这段代码,有必要深入剖析Clojure语言特性在其中所起的作用。这样有助于你理解Clojure语言如何以其抽象能力化繁为简,向用户呈现简洁的API,如表5-4所示。

表5-4 解剖Clojure API

Clojure语言特性 在DSL中的运用
高阶函数是实现DSL的基本要素之一 with-values的第一个参数是个函数➊;with-values函数返回另一个函数➋;这些都是Clojure语言把函数视同于一般的值的具体表现。Clojure支持高阶函数,所以你可以把函数当成参数来传递,当成返回值来获取,就像语言中的其他数据类型一样。 fn表示一个匿名函数➋。with-values所返回的这个匿名函数,对with-values的输入函数trade进行了增补,增加填充:tax-fees Map的行为
求值时指定词法上下文以控制其作用域 我们用新函数的参数来调用trade➌,然后把tax-fee的值补充到结果的Map里。 将let后面的多个绑定项依先后次序进行绑定;所以后一项绑定principal可以引用前一项绑定trdval
不可变性、实现持久化数据结构的能力 这段代码的最后一步,是把tax-feevalue参数凑成一个键-值对➍,放入trade函数在➌处返回的Map。 在这个过程中,原来的Map保持不变。Clojure实现了不可变和持久化的数据结构。因此,assoc-in每次调用都会返回一个新的Map,比原来的Map多了参数里指定的一对键值
函数可以自然地组合在一起 with-values的返回结果是一个函数,这有利于实现串联调用。我们可以把代码写成这个样子: (with-tax-fee trade (with-values :tax 12) (with-values :commission 23)) 我们把两次with-values调用跟原来的trade函数串联在一起。方法的串联调用体现了语言的组合性,我们说过这是一种优秀的DSL特质。Clojure等语言将函数视同一般的值,造就了很好的组合能力

那么,with-tax-fee怎样与with-values合力构成新的trade函数呢?这是我们下面要讨论的内容。

2.高阶函数实现的装饰器

我们还欠缺一个知识点才能讲解清楚with-tax-fee的原理,这个知识点虽然不怎么起眼,却是装饰器的实现基础。对比总是围绕着对象来展开的Ruby实现,我们越来越认识到,Clojure把函数放在了更重要的位置上,而且它为此准备了很多巧妙的手段。作为一种基本思路,Clojure DSL就应该从Clojure里发掘最自然、最符合语言习惯的手法来实现。下面的代码片段演示了Clojure语言的函数“串接(threading)”手法。

  1. (def trade
  2. (-> trade
  3. (with-values :tax 20)
  4. (with-values :commission 30)))

->宏将它的第一个参数传给参数序列中的下一个form,结果再传给再下一个form,如此依次传递下去,如同把这些form串成了一串。Clojure的函数串接让实现装饰器变得轻而易举,因为只要用->把一个函数串接到它的装饰器,就等于完成了对函数的重定义。代码清单5-15所示的装饰器实现代码就运用了这种技巧。这段代码来自Clojure语言Web开发框架Compojure(详见http://github.com/weavejester/compojure)。如果不熟悉Clojure语言,你可能要费一些功夫才能理解这里提到的编程概念。但一旦领会了Clojure用小的函数式抽象组合成大的函数式抽象的设计思路,你就能欣赏到上面短短四行代码的强烈美感,并且感激它们对DSL实现所做的贡献。

3.画龙点睛的Clojure宏

与其让用户自己串连装饰器,不如用一个宏把函数串接操作包装起来,除了简明易懂外,还不产生任何运行时的额外负担。于是就有了代码清单5-15。

代码清单5-15 Clojure装饰器

  1. (defmacro redef
  2. "重定义一个现有值,保持元数据不变。"
  3. [name value]
  4. `(let [m# (meta #'~name)
  5. v# (def ~name ~value)]
  6. (alter-meta! v# merge m#)
  7. v#))
  8. (defmacro with-tax-fee ➊ Clojure宏
  9. "将函数包入一个或多个装饰器。"
  10. [func & decorators]
  11. `(redef ~func (-> ~func ~@decorators)))

寥寥几行代码就勾勒出with-tax-fee的全貌——一个Clojure语言写就的,以非侵入方式向现有抽象增补新行为的编译时装饰器。with-tax-fee被实现为一个宏➊,因此它所封装的代码将在编译过程的宏展开阶段被释放出来。

在装饰过程中,输入函数的原值,即根绑定(root binding)会被我们重新定义,同时保持其元数据不变。这就是redef宏的工作。这个装饰过程不像Ruby元编程那样在执行阶段才实际发生;Clojure在运行时不存在任何元对象,所有的定义在宏展开阶段就已经确定了。

现在我们的DSL可以在交易抽象上补充税费计算逻辑了,这着实费了不少功夫。有了新的带装饰的trade函数,计算交易的现金价值的API也很容易能定义出来。我们之所以能实现有意义的领域抽象,讨论至今的Clojure特性功不可没。下面的API明白无误地说明了自己是如何计算交易净值的。任何熟悉金融交易和领域语言的人都能理解该函数的意图。

  1. (defn net-value [trade]
  2. (let [principal (:principal trade)
  3. tax-fees (vals (trade :tax-fees))]
  4. (reduce + (conj tax-fees principal)))) 组合子提高了抽象程度

这几行语句是对Clojure语言简洁性的最好证明。Clojure是一种紧凑的语言,适合在比较高的抽象层次上进行编程。上面最后一行代码用非常简练的语言描述了复杂的操作。reduce组合子递归地作用于后面的序列,依次施加指定的函数(+)➊。

4.我们的成果

我们的DSL只剩下最后的执行步骤,成功在望,反而不必急于一时。我们不妨耐心对照表5-5,总结一下到目前为止我们为实现计算交易现金价值的DSL做了哪些事情。

表5-5 DSL的演变过程

DSL的演变步骤 实现细节
1 设计交易的基本抽象 通过工厂方法trade完成以下工作: 1 从外部数据源接收数据 2 生成交易对象,对象表示成Clojure Map的形式
2 向领域对象注入新行为 采用以下技巧: - 装饰器模式 - Clojure宏 新的trade函数为了计算现金价值而被注入了税费方面的行为 如何将税费数据填充到交易中: 1 定义with-values函数,对trade函数的输出进行增补,加入新行为 2 通过装饰器模式将税费数据填入trade函数的输出 3 定义with-tax-fee宏,该宏可将with-values多次应用于一个现有函数 注意:with-tax-fee使用了编译时元编程技术,没有额外的运行负担
3 定义net-valu函数,计算交易的现金价值 net-value函数除了接收第2步修改后的trade函数,还将完成以下工作: 1 从交易信息中取得基本价值 2 从交易信息中取得税费数据 3 按照具体的领域逻辑计算现金净值

Clojure语言的设计理念不同于Ruby、Groovy、Java等语言。尽管扎根在Java的对象系统之上,它的语言习惯却是函数式的。Clojure编程不能沿用面向对象的思路。纵观本节的实现,我们其实并没有运用什么特殊的技法来设计DSL,你看到的全都是自然的Clojure编程风格。

图5-10描绘了Clojure DSL脚本的生命周期,请你多看两眼,加深理解。

enter image description here

图5-10 从Clojure DSL脚本到Clojure执行模型。留心观察DSL脚本被执行之前经历的一系列准备。我们在第1章就提过,语义模型是DSL脚本与执行模型之间的桥梁

感觉疲倦了吗?其实,我们还有一件关于Clojure DSL的事情留着没说,那就是Clojure REPL(read-eval-print-loop)这个让你能够即时观察、调整DSL的互动窗口。小憩一下,补充点能量,接下来是互动环节,少了活力可不行。我们会让你直接与Clojure解释器面对面地交流,实地运行本小节刚刚设计好的DSL。

5.4.3 通过REPL进行的DSL会话

Clojure等动态语言允许你直接通过一个REPL界面与语言运行时交互。(关于REPL的介绍请阅读http://en.wikipedia.org/wiki/Read-eval-print_loop)。REPL让你实时观察DSL,即时修改其行为并看到修改的效果。这项特性绝对是实现DSL无缝改进的有力帮手。

我们的DSL的简洁度和表现力都达到了相当高的水平,举例来说,用我们的DSL表达现金价值的计算逻辑,只需要写(net-value (trade request))这么简单的一句话。你可以即时创建一笔交易,放在REPL里运行,然后修改trade函数,以装饰器的形式增加业务规则。下面是用我们的DSL在Clojure REPL中进行的一段会话:

  1. user> (def request {:ref-no "r-123", :account "a-123",
  2. :instrument "i-123", :unit-price 20,
  3. :quantity 100})
  4. #'user/request
  5. user> (trade request)
  6. {:ref-no "r-123", :account "a-123", :instrument "i-123",
  7. :principal 2000, :tax-fees {}}
  8. user> (with-tax-fee trade
  9. (with-values :tax 12)
  10. (with-values :commission 23))
  11. #'user/trade
  12. user> (trade request)
  13. {:ref-no "r-123", :account "a-123", :instrument "i-123",
  14. :principal 2000, :tax-fees {:commission 460, :tax 240}}
  15. user> (with-tax-fee trade
  16. (with-values :vat 12))
  17. #'user/trade
  18. user> (trade request)
  19. {:ref-no "r-123", :account "a-123", :instrument "i-123",
  20. :principal 2000, :tax-fees {:vat 240, :commission 460, :tax 240}}
  21. user> (net-value (trade request))
  22. 2940

好的DSL应该对外呈现易于使用、充分体现领域语汇精髓的API界面,同时将其复杂的实现隐藏在API之后。这是优秀DSL的决定性品质之一。本小节的Clojure REPL交互真实地展现了DSL的简洁度。我们的DSL始终让你觉得它处处呼应了交易员的实际工作用语,虽然是领域语言,却能刚好用Clojure语言来实现。

一名合格的设计者在学习任何新范式的时候,都不要忘记掌握它的缺陷。我们已经讲解了不少正面的模式、惯用法和最佳实践,为你指明DSL的实现道路。下一节要说说反面的缺陷,提醒你避开途中的险阻。