5.1 动态类型成就简洁的DSL

内部DSL将领域语义呈现为更易读的形式,这是内部DSL在实现语言上面增加的一种重要性质。内部DSL用领域用户能明白的语句、语汇向用户解释实现的含义。

enter image description here当没有程序员背景的领域专家阅读一段DSL脚本时,他应该能够读懂其中的领域规则。能够疏通开发者与领域从业者之间的沟通渠道,就是DSL的真正价值。我不鼓吹让每个非程序员背景的领域专家都能用DSL编写程序,但至少应该做到让领域专家能够理解DSL脚本中的领域语义。

动态类型语言写成的程序不带类型标注,这本身就减少了视觉上的干扰,可以更清楚地说明编程者的意图。这样写出来的代码可读性较好,而易读性正是DSL区别于一般API的基本特质之一。我会用下面几个小节说明用动态类型语言开发出来的DSL所具备的三大重要特质:

  • 更易读,因为没有类型标注的干扰(第5.1.1小节);
  • 鸭子类型,涉及DSL接口契约的设计思路(第5.1.2小节);
  • 元编程,从DSL实现中清除死板代码的一种方式(第5.1.3小节)。

5.1.1 易读

DSL的读者都希望语言自然流畅,不愿意其中掺入不必要的复杂内容。编程语言的类型系统有可能助长DSL的非本质复杂性。如果你用了一种类型系统像Java那么琐细的实现语言,很可能最后出来的DSL要在抽象身上附加一串不必要的类型标注。动态类型语言不要求提供类型标注,所以相比同等情况下的静态类型语言实现,能更清楚地表达编程者的意图。至于意图背后的实现,倒不一定更容易理解。(第5.5节将讨论基于动态语言的DSL实现中的常见陷阱,届时你会看到这方面的例子。)总体而言,动态类型语言的语法较为简明,制作出来的DSL及其实现也因此较为易读。

DSL是否易读可以直观地感受出来,不过动态语言还有另一个特点,同样在内部DSL的设计和实现中扮演了重要角色。动态类型语言轻仪节而重语义。它们比静态类型语言言辞精炼,虽然表现出来的还是抽象组成的层次结构,但背后的思维是不同的。不同的地方在于抽象如何响应发送给它的消息。

5.1.2 鸭子类型

鸭子类型不一定是弱类型。假设你在一个对象上调用某消息,如果该对象满足消息所要求的契约,就会得到响应;否则,消息将沿着该对象的继承链向上传播,直至找到一个祖先对象满足消息契约为止。如果一直上溯到根对象都找不到合适的方法来响应该消息,用户将得到一个NoMethodError。编译时并不确定在这个对象上调用那则消息是否有效,因为不存在这方面的静态检查。但用户可以在运行时修改对象的响应目标,改变其方法和属性。对于任意给定的消息,如果在消息被调用的那一刻,目标对象支持该消息,那么就认为调用是有效的。这个机制被称为鸭子类型(duck typing),Ruby、Groovy、Clojure等众多语言都实现了这种机制。静态类型语言如Scala,也可以实现鸭子类型,我们将在第6章论及。

1.通过鸭子类型实现的多态

动态语言中的鸭子类型对于我们实现DSL有什么好处呢?简单来说还是那句话,我们牺牲静态类型安全换取简洁的实现。你不需要静态地声明接口,也不需要依赖继承关系去实现多态。只要消息的接收者实现了正确的契约,它就可以合理地响应消息。图5-2描绘了通过鸭子类型来实现多态的情形。

enter image description here

图5-2 鸭子类型构成的多态。FooBar抽象并没有任何共同的基类,但在支持鸭子类型的语言里,可以把它们看作是多态的

接下来我们看一个金融交易领域的例子。我们首先做一个用了接口的Java实现,然后做一个用了鸭子类型的Ruby实现,让你在对比之下体会后者的简洁。

2.金融交易领域的例子

交易所里履行的交易有许多类型,类型的划分与被交易的票据种类有关。(交易、票据、成交这些概念你应该已经熟悉了,万一有所遗忘,请翻查前面几章的插入栏。)证券交易是涉及股票和固定收益的买卖。外汇交易涉及以即期(spot)或掉期(swap)交易的形式交换各种外国货币。一般地,在Java这样的静态类型语言里面,你会将这两个概念建模为同一接口下的两个特殊化抽象。继承链的定义也是静态的,如下面的代码片段所示:

  1. interface Trade {
  2. float valueOf();
  3. }
  4. class SecurityTrade implements Trade {
  5. public float valueOf() { //.. }
  6. }
  7. class ForexTrade implements Trade {
  8. public float valueOf() { //.. }
  9. }

如果有一个计算交易现金价值的方法,要求可以传递给它任意类型的Trade对象,那么实现出来会是这个样子:

  1. public float cashValue(Trade trade) {
  2. trade.valueOf();
  3. }

cashValue方法的参数被限定为实现了valueOf方法的最高一级静态类型。这项约束由Java编译器静态地核查。作为对比,请你看另一个实现,下面的代码清单用了鸭子类型来实现cash_value方法。

代码清单5-1 鸭子类型构成的多态

  1. class SecurityTrade
  2. ## ..
  3. def value_of
  4. ## ..
  5. end
  6. end
  7. class ForexTrade
  8. ## ..
  9. def value_of
  10. ## ..
  11. end
  12. end
  13. def cash_value(trade)
  14. trade.value_of
  15. end
  16. cash_value(SecurityTrade.new)
  17. cash_value(ForexTrade.new)

这个实现没有前面那些周边设施,不存在静态继承关系;只要传递的对象实现了value_of方法, cash_value就能执行无误。假如传递一个没有实现value_of方法的无关对象,会怎么样呢?毫无疑问,cash_value会在运行时卡壳。所以你应该有全面的单元测试去核查保护各处契约。

单元测试时,由于不需要穷于应付静态类型的安全,很容易构建用于测试的模拟对象。记住,在一种支持鸭子类型的语言里面,不要去检查类型,也不要试图用动态语言来模拟静态类型。这是一种截然不同的抽象设计思路。抽象有没有实现它应该向客户提供的契约,这才是你的测试套件所要针对的目标。

鸭子类型令你无需写出静态的约束检查代码。仅仅这一条就立即让DSL实现简洁了许多,同时编写者的意图也表达得很清晰。我们在第4.2.2小节,在Ruby中通过动态mixin实现装饰器的时候已经讨论过这一点。最后得到的DSL如下所示:

  1. Trade.new('r-123', 'a-123', 'i-123', 20000).with TaxFee, Commission

注意,我们向Trade抽象里混入了TaxFeeCommission两个模块,交易的总现金价值通过Ruby的鸭子类型计算得出。

接着我们要与第4章认识的元编程技术再次碰面。动态类型语言用元编程技术可以把你从重复性的死板代码中解放出来。

5.1.3 元编程——又碰面了

除了省去类型标注外,动态类型还有什么办法令DSL语言更简洁?答案我们在上一章就知道了,它可以通过语言本身的机制生成重复性的代码结构,免除手工编写的负担。简洁的实现对于DSL的设计者很重要,而简洁的DSL编程界面对于DSL的使用者同样重要。Ruby和Groovy都拥有非常精良的元编程设施,可以在运行时引入新的方法和属性,我们已经在第2.3.1小节和第4.2节讨论过。举一个例子来回顾动态类型对于DSL实现简洁性的正面影响吧。下面的代码清单用Groovy语言演示了一个运用元编程和闭包来实现的XML建造器。

代码清单5-2 Groovy语言实现的XML建造器,展示动态元编程的力量

  1. def clientOrders = //..
  2. builder = new groovy.xml.MarkupBuilder()
  3. builder.orders {
  4. clientOrders.each {ord ->
  5. order(type: ord.getBuySell()) { Groovy的动态方法分发
  6. instrument(ord.getSecurity())
  7. quantity(ord.getQuantity())
  8. price(ord.getLimitPrice())
  9. }
  10. }
  11. }

例中Groovy语言实现的MarkupBuilder对于orderinstrumentquantityprice等方法一无所知➊。语言运行时通过Groovy的动态方法分发机制以及methodMissing()钩子,拦截所有未定义的方法调用。Ruby语言也有类似的手法。动态类型语言提供了拦截器来应付所有未定义的方法。这样的技巧使程序更简洁、更动态,同时又能保留必要的表现力。

我们分析了动态语言实现的DSL的三项代表性特质。第一项易读性说的是DSL脚本的表面语法;另外两项,鸭子类型和元编程则更多地与实现技术有关。我们试着列举一下Ruby、Groovy、Clojure语言各具备哪些特性,有利于创作表现力充沛的DSL。

5.1.4 为何选择Ruby、Groovy、Clojure

Ruby、Groovy、Clojure三种语言都完全具备前述动态类型语言的三大特质,它们都是适合实现内部DSL的优秀宿主语言。表5-1总结了它们的语言特性。

表5-1 Ruby、Groovy和Clojure语言具备以下特质,使它们成为内部DSL实现语言的优秀候选者

易读性鸭子类型元编程
Ruby语法灵活,无需类型标注,文字表达手段丰富 支持鸭子类型,且可用responds_to?检查一个类是否响应给定的消息 具有很强的反射式和生成式元编程能力
Groovy语法灵活,类型标注可选,文字表达手段丰富支持鸭子类型;允许无公共基类的多态运行时元编程能力强,通过Groovy元对象协议(MOP)实现
Clojure语法灵活,但作为Lisp的变体,为前缀语法形式所限。允许程序员提供可选的类型提示(type hint)以利方法分发,可避免像Java那样的反射式调用如同Ruby或Groovy,也支持鸭子类型可经由宏机制实现编译时元编程。Clojure拥有极强的可塑性,可视DSL之需灵活扩展

虽然Ruby、Groovy、Clojure有一些共同的特点,但它们在DSL实现方面的差别其实也很大,足以使我们分成三节来分别讨论。这三种语言都在JVM上运行,都拥有很强的元编程能力,也都迅速成为了主流的开发语言。然而就在与JVM集成的方式这个很基本的问题上,它们却给出了不一样的答案。图5-3总结了这三种语言的一些异同。

enter image description here

图5-3 Ruby、Groovy、Clojure呈现了多样化的DSL实现方案

本章我们将分别用这三种语言探索内部DSL的实现。在讨论过程中,我们将观察每一种语言的不同特性,同时剖析每项特性在第4章深入讨论过的那些模式中所起的作用。