6.8 DSL中的Monad化结构
我一直反复强调,抽象组合得越好,DSL的可读性就越强。当针对“运算”进行抽象时,函数式编程提供的组合能力要超过面向对象编程模型。这是因为函数式编程将各种运算都当做纯数学函数来使用,不产生改变状态的副作用。如果函数与改变状态分离,它就成了一种不依赖于任何外部上下文,可以单独验证的抽象。借助函数式编程提供的数学模型,运算可以被组合成一些函数式的组合体。我不准备深入介绍范畴论或者其他具有类似功用的数学理论体系。你只要记住,函数的组合性意味着我们可以用简单的构件块搭建出复杂的抽象。
1. 什么是Monad
Monad可以理解成函数组合或者加强版的绑定。按照Monad的规则构造出来的抽象,可以在优美的组合语义指挥之下,用来构造更高阶的抽象。
将运算的结构使用“值”和“使用这些值的运算序列”来表示,我们就得到一个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由以下三部分定义。
一个抽象
M[A]
,其中M
是类型构造函数。在Scala语言中可以写成class M[A]
,或者case class M[A]
,又或者trait M[A]
。一个
unit
方法(unit v
)。对应Scala中的构造函数new M(v)
或者M(v)
的调用。一个
bind
方法,起到将运算排成序列的作用。在Scala中通过flatMap
组合子来实现。bind f m
对应的Scala语句是m flatMap f
。例如
List[A]
是Scala语言中的一个Monad。它的unit
方法由构造函数List(…)
定义,它的bind
方法则由flatMap
组合子实现。那么是不是任何抽象只要具备以上三部分,就成了一个Monad呢?不一定。Monad还必须满足以下三条规则。
右单位元(identity)。即对于任意Monad
m
,有m flatMap unit => m
。以Scala的List
Monad来说,我们可以得出List(1,2,3) flatMap {x => List(x)} == List(1,2,3)
。左单位元(unit)。即对于任意Monad
m
,有unit(v) flatMap f => f(v)
。换成Scala的List
Monad,这个关系意味着List(100) flatMap {x => f(x)} == f(100)
,其中f
返回一个List
。结合律。即对于任意Monad
m
,有m flatMap g flatMap h => m flatMap {x => g(x) flatMap h}
。这个定律告诉我们运算的结果取决于运算顺序,但不受嵌套的影响。作为练习,读者可以在ScalaList
身上验证一下这个定律。
2. Monad如何降低非本质复杂性
代码清单6-24中使用Java编写的例子是Web交易应用中的一个典型操作。我们凭一个键从HttpRequest
或HttpSession
中取出对应的值。我们取出来的这个值是某一笔交易的编号,用这个编号可以从数据库中查询到具体的交易,获得对应的Trade
对象。
代码清单6-24 Java对运算中分支路径的处理方式
String param(String key) {
//.. 从请求或会话获取参数值
return value;
}
Trade queryTrade(String ref) {
//..查询 执行数据库查询
return trade
}
public static void main(String[] args) {
String key;
//.. 设置要获取的键
String refNo = param(key);
if (refNo == null) { ➊ 检查空值
//.. 异常处理
}
Trade trade = queryTrade (refNo);
if (trade == null) { ➊ 检查空值
//.. 异常处理
}
}
这段代码表现出一定程度的非本质复杂性➊,对抽象的表面语法造成了污染。在这段从上下文参数获取领域对象的运算中,每一步都要执行空值检查,且每次检查都是显式进行的。代码清单6-25中的Scala代码与上面的代码功能完全相同,但利用了Scala的Monad化语法结构for comprehension。
代码清单6-25 Scala的Monad化语法结构for comprehension
def param(key: String): Option[String] = { ➊ Monad化的返回
//..
}
def queryTrade(ref: String): Option[Trade] = { ➊ Monad化的返回值
//..
}
def main(args: Array[String]) {
val trade =
(
for { ➋ for comprehensions
r <- param("refNo")
t <- queryTrade (r)
}
yield t
) getOrElse error("not found")
//..
}
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
操作就清楚地显露出来了。
param("refNo") flatMap {r =>
queryTrade(r) map {t =>
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
package monad
import api._
class TradeDslM {
def validate(trade: Trade): Option[Trade] = //..
def enrich(trade: Trade): Option[Trade] = //..
def journalize(trade: Trade): Option[Trade] = //..
}
object TradeDslM extends TradeDslM
用一个for comprehension套住上面的DSL,即可对一个交易的集合调用流程方法组成的序列:
import TradeDslM._
val trd =
for {
trade <- trades
trValidated <- validate(trade)
trEnriched <- enrich(trValidated)
trFinal <- journalize(trEnriched)
}
yield trFinal
除了改用Monad的绑定操作来串联各步骤,这段代码的功能与代码清单6-20相同。原先基于偏函数的实现只能串联类型完全一致的操作。而这段for comprehension里面的操作序列,前后操作的类型并不完全一致。trades
是Iterable
类型的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
展开形式,它是下面这个样子的:
trades flatMap {trade =>
validate(trade) flatMap {trValidated =>
enrich(trValidated) flatMap {trEnriched =>
journalize(trEnriched) map {trFinal =>
trFinal
}
}
}
}
显然这种写法的编程味道要浓很多,还是之前用for
语句的版本更适合领域用户阅读。
读完本节,你知道Monad是Scala提供的另一种抽象组织手段。它与偏函数有细微的差别。它能用一种纯数学的方式,把抽象按照依赖关系串联起来。Scala自带了不少Monad化的构造单元,设计DSL时别忘了善用这些素材。