6.8 DSL中的Monad化结构

我一直反复强调,抽象组合得越好,DSL的可读性就越强。当针对“运算”进行抽象时,函数式编程提供的组合能力要超过面向对象编程模型。这是因为函数式编程将各种运算都当做纯数学函数来使用,不产生改变状态的副作用。如果函数与改变状态分离,它就成了一种不依赖于任何外部上下文,可以单独验证的抽象。借助函数式编程提供的数学模型,运算可以被组合成一些函数式的组合体。我不准备深入介绍范畴论或者其他具有类似功用的数学理论体系。你只要记住,函数的组合性意味着我们可以用简单的构件块搭建出复杂的抽象。

1. 什么是Monad

Monad可以理解成函数组合或者加强版的绑定。按照Monad的规则构造出来的抽象,可以在优美的组合语义指挥之下,用来构造更高阶的抽象。

enter image description here将运算的结构使用“值”和“使用这些值的运算序列”来表示,我们就得到一个Monad抽象。许多Monad再按照依存关系组合起来,可以构成更大规模的运算。Monad的理论基础是令人望而生畏的范畴论,但如果你有兴趣了解,那么6.10节文献[10]可以作为初步的阅读材料。一般读者并不需要深究,放松就好。

这里的讨论不会深入到理论层面,只会针对设计Scala DSL的需要,从实用的角度探讨Monad的一些特性。等到第8章介绍Scala的分析器组合子时,再展示更多的Monad化构造单元。本节讨论的主要对象是Scala语言中一些具备Monad性质的操作,它们可以使DSL的组织比面向对象编程中的对应结构更加优美。

本节的补充内容简单介绍了Monad。Monad概念的详细信息请参阅6.10节文献[9]。

Monad小讲座

Monad是一种可以绑定一系列运算的抽象。这里没有给出理论化的一般性定义,而是试着用Scala的语汇来界定Monad有哪些性质。(如果按照传统思路,需要动用范畴论或Haskell语言,从一阶逻辑的基本原理说起;以Scala语言作为解释基础似乎更实用一些)。

一个Monad由以下三部分定义。

  1. 一个抽象M[A],其中M是类型构造函数。在Scala语言中可以写成class M[A],或者case class M[A],又或者trait M[A]

  2. 一个unit方法(unit v。对应Scala中的构造函数new M(v)或者M(v)的调用。

  3. 一个bind方法,起到将运算排成序列的作用。在Scala中通过flatMap组合子来实现。bind f m对应的Scala语句是m flatMap f

例如List[A]是Scala语言中的一个Monad。它的unit方法由构造函数List(…)定义,它的bind方法则由flatMap组合子实现。

那么是不是任何抽象只要具备以上三部分,就成了一个Monad呢?不一定。Monad还必须满足以下三条规则。

  1. 右单位元(identity)。即对于任意Monad m,有m flatMap unit => m。以Scala的List Monad来说,我们可以得出List(1,2,3) flatMap {x => List(x)} == List(1,2,3)

  2. 左单位元(unit)。即对于任意Monad m,有unit(v) flatMap f => f(v)。换成Scala的List Monad,这个关系意味着List(100) flatMap {x => f(x)} == f(100),其中f返回一个List

  3. 结合律。即对于任意Monad m,有m flatMap g flatMap h => m flatMap {x => g(x) flatMap h}。这个定律告诉我们运算的结果取决于运算顺序,但不受嵌套的影响。作为练习,读者可以在Scala List身上验证一下这个定律。

2. Monad如何降低非本质复杂性

代码清单6-24中使用Java编写的例子是Web交易应用中的一个典型操作。我们凭一个键从HttpRequestHttpSession中取出对应的值。我们取出来的这个值是某一笔交易的编号,用这个编号可以从数据库中查询到具体的交易,获得对应的Trade对象。

代码清单6-24 Java对运算中分支路径的处理方式

  1. String param(String key) {
  2. //.. 从请求或会话获取参数值
  3. return value;
  4. }
  5. Trade queryTrade(String ref) {
  6. //..查询 执行数据库查询
  7. return trade
  8. }
  9. public static void main(String[] args) {
  10. String key;
  11. //.. 设置要获取的键
  12. String refNo = param(key);
  13. if (refNo == null) { 检查空值
  14. //.. 异常处理
  15. }
  16. Trade trade = queryTrade (refNo);
  17. if (trade == null) { 检查空值
  18. //.. 异常处理
  19. }
  20. }

这段代码表现出一定程度的非本质复杂性➊,对抽象的表面语法造成了污染。在这段从上下文参数获取领域对象的运算中,每一步都要执行空值检查,且每次检查都是显式进行的。代码清单6-25中的Scala代码与上面的代码功能完全相同,但利用了Scala的Monad化语法结构for comprehension。

代码清单6-25 Scala的Monad化语法结构for comprehension

  1. def param(key: String): Option[String] = { Monad化的返回
  2. //..
  3. }
  4. def queryTrade(ref: String): Option[Trade] = { Monad化的返回值
  5. //..
  6. }
  7. def main(args: Array[String]) {
  8. val trade =
  9. (
  10. for { for comprehensions
  11. r <- param("refNo")
  12. t <- queryTrade (r)
  13. }
  14. yield t
  15. ) getOrElse error("not found")
  16. //..
  17. }

main方法中的Monad化结构最终怎么串联起来,创建出trade对象,对此我们进行详细分析。

param返回的Option[String]➊是一个Monad。按照Scala的设计,Option[]用于表示一则有可能不产生任何结果的运算。queryTrade返回的Option[Trade]➊也是一个Monad,只不过它的类型不同于Option[String]。我们希望两步运算的串联满足一定的条件,即当param返回空值时,queryTrade必须不被调用。代码清单6-24用了显式的空值检查来实现这样的条件。而这里因为利用了Monad化结构,让Option[]在其实现内部负责空值检查的例行工作,表面上的代码得以保持简介,摆脱了非本质复杂性➋。

Monad是怎么串联两步运算的呢?是通过本节补充内容中介绍的Monad三要素之一——bind操作。bind操作在Scala语言中被实现为flatMap组合子,而for comprehension➋只不过是包裹在flatMap外面的一层语法糖,从下面的代码片段可以看穿这一点。

剥掉for comprehension糖衣,里面掩盖着的bind操作就清楚地显露出来了。

  1. param("refNo") flatMap {r =>
  2. queryTrade(r) map {t =>
  3. t}} getOrElse error("not found")

flatMap组合子(等价于Haskell的>>=操作)在片段中起到一种承上启下的作用。重要的bind操作将param的输出对接到queryTrade的输入,同时在操作内部处理所有必要的空值检查。for comprehension在flatMap组合子的基础上提供更高阶的抽象,进一步改善DSL的可读性和表达能力。

对Monad、flatMap和for comprehension的深入讨论超出了本书的范围。目前来说,只要知道Monad化结构和操作能简化DSL的实现就可以了。我们刚刚用Monad作为手段,在不引入非本质复杂性的前提下,对存在依赖关系的运算进行排序。这只是Monad最普通不过的一种用法而已。除此之外,Monad还广泛用作一种机制,解释纯函数式语言的副作用,处理状态变化、异常、continuation等运算。显然,Monad的这些用法都可以用来改善DSL的表现力。Scala语言中Monad的更多详细信息请参考6.10节文献[10]。

为了给讨论画上一个漂亮的句号,我们最后用一个例子来说明Monad如何提高DSL抽象的表现力。这个例子是对代码清单6-20中交易处理流程DSL的重新实现,但我们用Monad化的for comprehension取代原来的偏函数来排列操作的顺序。这个练习目的是让你学会用Monad的思维去构建DSL。我们会尽量让例子保持简单,仅针对性地演示应该怎样设计各步运算,以便于通过for comprehension进行串联。

3. 设计Monad化的交易DSL

不再多说,我们这就换一种方式重新定义代码清单6-17的TradeDsl。修改后,所有的流程方法都不再返回PartialFunction,而是分别返回一个Monad(Option[])。

代码清单6-26 Monad化的TradeDsl

  1. package monad
  2. import api._
  3. class TradeDslM {
  4. def validate(trade: Trade): Option[Trade] = //..
  5. def enrich(trade: Trade): Option[Trade] = //..
  6. def journalize(trade: Trade): Option[Trade] = //..
  7. }
  8. object TradeDslM extends TradeDslM

用一个for comprehension套住上面的DSL,即可对一个交易的集合调用流程方法组成的序列:

  1. import TradeDslM._
  2. val trd =
  3. for {
  4. trade <- trades
  5. trValidated <- validate(trade)
  6. trEnriched <- enrich(trValidated)
  7. trFinal <- journalize(trEnriched)
  8. }
  9. yield trFinal

除了改用Monad的绑定操作来串联各步骤,这段代码的功能与代码清单6-20相同。原先基于偏函数的实现只能串联类型完全一致的操作。而这段for comprehension里面的操作序列,前后操作的类型并不完全一致。tradesIterable类型的List,每次迭代它都产生一个Trade对象。我们并不需要特意检查列表的结尾,因为List也像Option[]一样是个Monad,其内部的flatMap组合子会处理好类似的边界条件。validate返回一个Option[Trade],可能是Some(trade),也可能是None。当validate的输出被送入enrich时,不需要做任何显式的空值检查,也不需要进行任何Option[Trade] -> Trade显式转换。只要串联用的管道是List[]Option[]等Monad化构造,flatMap组合子会自动完成所有的绑定。从这个意义上说,通过Monad绑定来串联操作,比代码清单6-17通过偏函数来串联效果更好。如果设计得当,Monad化的操作可以成就表现力强的DSL,配合Scala的for表达式(或Haskell的do notation)语法糖一起使用,效果尤佳。

如果你想知道上面片段的flatMap展开形式,它是下面这个样子的:

  1. trades flatMap {trade =>
  2. validate(trade) flatMap {trValidated =>
  3. enrich(trValidated) flatMap {trEnriched =>
  4. journalize(trEnriched) map {trFinal =>
  5. trFinal
  6. }
  7. }
  8. }
  9. }

显然这种写法的编程味道要浓很多,还是之前用for语句的版本更适合领域用户阅读。

读完本节,你知道Monad是Scala提供的另一种抽象组织手段。它与偏函数有细微的差别。它能用一种纯数学的方式,把抽象按照依赖关系串联起来。Scala自带了不少Monad化的构造单元,设计DSL时别忘了善用这些素材。