Clojure程序员的Monad之旅(Part 3)
在开始monad高级话题之前,我们简单回顾一下monad的定义(参考Part 1和Part 2):
1. 一种数据结构,表现运算的结果,或者运算本身。
2. 使用m-result函数,把一般的值,转换成等价的monad数据结构。
3. 使用m-bind函数,绑定一个使用monad数据结构表示的运算的值到一个名称(使用接受一个参数的函数),使这个值在接下来的运算中可用。
以sequence monad为例,数据结构为sequence,表示最终结果非唯一的运算;m-result是list函数,把每次运算结果转换成一个序列,序列的元素是运算的每个值转成的list;m-bind函数继续对序列进行处理,并把结果移出list嵌套。
以上3个要素定义了一个monad,有些monad除了这些必要条件外,还增加了2个额外的定义,以实现特殊功能。这两个函数就是m-zero和m-plus。m-zero代表一个特殊的monad值,表示当一个运算没有返回值时的返回结果。例如:maybe monad中的nil,通常代表了计算过程失败。另一个例子是sequence monad中的空序列。identity monad 则是不包括m-zero的例子。
m-plus函数把多个计算的结果合并成一个。对sequence monad来说,就是把多个sequence连接成一个;对maybe monad来说,就是返回参数列表中第一个不是nil的参数。
m-zero和m-plus需满足的条件:
(= (m-plus m-zero monadic-expression)
(m-plus monadic-expression m-zero)
monadic-expression)
一句话,就是组合m-zero和任意的monad表达式,必须返回等价的值。可以从maybe和sequence中验证这一点。
在monad中使用m-zero的好处之一就是可以使用条件分支。在Part1中,我们提及了:when表达式,现在我们就来讨论:
2 :when (odd? a)]
3 (* 2 a))
使用domonad来表示:
2 [a (range 5)
3 :when (odd? a)]
4 (* 2 a))
domonad宏把let形式的语法,转换成m-bind和m-result的组合式,a (range 5) 等价于
在remaining-steps中:when语句被特殊处理
这个例子完全展开后就成为
2 (if (odd? a) (m-result (* 2 a)) m-zero)))
把m-bind,m-result和m-zero的对应实现替换进来
2 (if (odd? a) (list (* 2 a)) (list))) (range 5)))
map的结果是包含1个或0个元素的list组成的序列:对于奇数的值,返回的是(),即m-zero的值,对于偶数,返回(结果),即m-result的值。concat函数把这些list连接起来成为最后的结果。
对于m-plus,一般跟maybe monad和sequence monad一起使用。一个典型的应用是查找(比如一个语法解释器,一个正则搜索,一个数据库查询),可能成功(有返回结果)或失败(无返回结果)。m-plus用于把查找的结果组合返回(sequence monad),或者一直查找,直到找到一个符合条件的值(maybe monad)。原则上来说,使用m-zero比使用m-plus更合适,任何使用m-plus的地方,都可以用m-zero实现。
讲完理论,让我们熟悉几个monad。在本节的开头,我提到monad使用的数据结构并不总是表示运算步骤的结果,有时表示的是运算步骤本身。比如,state monad,它的数据结构是一个函数。
state monad的作用是把状态算法,用纯函数式的方式来实现。状态算法需要更新一些变量的值,在命令式语言中,这很普遍,但是却不符合纯函数式编程的基本原则,因为纯函数式语言是不允许可变数据结构的。一个解决方法是在纯函数语言中使用特殊的数据项(典型的就是Clojure的map)来存储算法所需的可变数据的当前值。在命令式编程中,一个函数可以通过传参,修改变量的当前值,并返回更新后的值。对状态的修改变成显式的数据项,在函数中传递。state monad可以隐藏状态传递的过程,并且写出的算法使用命令式风格来查询和修改状态。
state monad和我们之前看到的monad不同,他的数据结构是一个函数,即运算本身。state monad的值是一个接受一个参数的函数,这个函数就是当前状态的运算。并且返回一个vector,vector长度为2,包括计算结果和更新后的状态。实际上,这些函数都是典型的闭包,并且你在程序中使用的代码和函数,都产生这样的闭包。就像你看到的,state monad允许你组合这些函数,使你的程序看起来跟命令式的一样,尽管他们是纯函数式的。
让我们从一个简单的常见场景开始:你要处理的state以map形式存储。你可能认为map是一个命令式语言中的概念,每个key定义一个变量,两个基本的操作来读取和修改值。在Clojure的monad库中已经提供了这个功能,但不论如何,我这里还是要展示一下(巫云@:如果已经引入了clojure.algo.monads,会发生函数名冲突,可以改名后测试)
首先,我们看fetch-val函数,用于读取一个变量的值:
2 (fn [s]
3 [(key s) s]))
这里我们定义一个生成state monad值的函数(巫云@:原文为state-monad-value-generating function)。它返回一个状态变量s,当执行时,返回一个返回值和新状态组成的vector。返回值是state在map对应的key值。这个函数不改变状态,只是查找。
下面我们来看set-val,返回前一状态的值和包含新状态的map组成的vector:
2 (fn [s]
3 (let [old-val (get s key)
4 new-s (assoc s key val)]
5 [old-val new-s])))
使用这两个元素,我们开始进行组合。我们来定义一个声明,把一个变量的值复制到另一个变量,并返回被修改变量的原值:
2 (domonad state-m
3 [from-val (fetch-val from)
4 old-to-val (set-val to from-val)]
5 old-to-val))
那么copy-val函数返回什么呢?一个state-monad值,即一个函数,接受一个参数,state变量s。执行时,返回变量的旧值,和拥有有新值的state的拷贝。让我们试一下:
2 computation (copy-val :b :a)
3 [result final-state] (computation initial-state)]
4 final-state)
结果为{:a 2, :b 2},正如我们期望的。但是这是如何发生的呢?为了理解state monad,我们需要看一看m-result和m-bind的定义。
m-result没有什么特别,它返回一个函数,根据s,返回s在map中的值v,以及未更新过的状态s:
m-bind的实现比较有趣:
2 (fn [s]
3 (let [[v ss] (mv s)]
4 ((f v) ss))))
显然,他返回一个以状态变量s为参数的函数,执行这个函数时,首先对s运行mv(m-bind操作链中绑定的第一个声明)。返回值解析到结果v和新的状态ss。第一步的结果v,被后面的操作f使用(跟我们看过的其他m-bind一样)。调用的结果返回另一个state-monad值,它也是一个接受状态变量参数的函数。当我们进入(fn [s] …)时,我们已经处于执行阶段,于是我们必须对状态ss调用这个函数。
[from-val (fetch-val from)
old-to-val (set-val to from-val)]
old-to-val) 展开来理解一下运行步骤。)
state monad是一个非常基础的monad之一,许多monad都是state monad的变形。通常一个这样的变形在m-bind中增加一下东西,来说明状态已经被处理。一个例子就是clojure.contrib.stream-utils里的stream monad。它的state描述了数据组成的流,m-bind函数除了state monad基本的工作外,还检测非法值以及end-of-stream的条件。
state monad中的一个变形由于使用非常频繁,以至于成为了一个标准monad,这就是writer monad。它的state是一个累加器(在clojure.contrib.accumulators中定义),运算可以通过write函数进行累加。这个名字来自一个特殊的应用程序:loggin,它在identiy monad(Clojure的let就是一个identiy monad)中进行了运算。假设你想增加一个运算协议,使用list或string来累加计算过程中的信息,只需要修改write monad对应的identity monad,然后在需要的地方调用write。
这里有一个抽象的例子:著名的Fibonacci函数的最直接(同时也是效率最低的)实现:
2 (if (< n 2)
3 n
4 (let [n1 (dec n)
5 n2 (dec n1)]
6 (+ (fib n1) (fib n2)))))
我们来增加一个运算协议,以便看看,在整个计算过程中,发生了哪些调用。首先,我们重写这个例子,定义每一个计算步骤
2 (if (< n 2)
3 n
4 (let [n1 (dec n)
5 n2 (dec n1)
6 f1 (fib n1)
7 f2 (fib n2)]
8 (+ f1 f2))))
接着,我们用domonad代替let,并使用带有一个vetor累加器的writer monad:
2
3 (with-monad (writer-m accu/empty-vector)
4 (defn fib-trace [n]
5 (if (< n 2)
6 (m-result n)
7 (domonad
8 [n1 (m-result (dec n))
9 n2 (m-result (dec n1))
10 f1 (fib-trace n1)
11 _ (write [n1 f1])
12 f2 (fib-trace n2)
13 _ (write [n2 f2])]
14 (+ f1 f2)))))
最后,我们运行 fib-trace查看结果:
(fib-trace 3)
=> [2 [[1 1] [0 0] [2 1] [1 1]]]
第一个元素,是fib运算执行的返回值2;第二个元素,是一个协议vector,包含每步递归调用的参数和结果。
如果当我们把write调用注释掉,并且把monad类型换成identity-m时,就会变成一个标准的,无协议的fib函数。请自己尝试。
(with-monad identity-m
(defn fib-trace [n]
(if (< n 2)
(m-result n)
(domonad
[n1 (m-result (dec n))
n2 (m-result (dec n1))
f1 (fib-trace n1)
f2 (fib-trace n2)]
(+ f1 f2)))))
Part 4 将会展示:如何通过组合名为monad transformers的monad构建组件来定义我们自己的monad。在演示段落,我将解释probability monad,以及如何通过跟maybe-transformer的组合把它应用到贝叶斯(Bayesian)估算。