Clojure程序员的Monad之旅(Part 3)


   在开始monad高级话题之前,我们简单回顾一下monad的定义(参考Part 1Part 2):

1.      一种数据结构,表现运算的结果,或者运算本身。

2.       使用m-result函数,把一般的值,转换成等价的monad数据结构。

3.      使用m-bind函数,绑定一个使用monad数据结构表示的运算的值到一个名称(使用接受一个参数的函数),使这个值在接下来的运算中可用。


sequence monad为例,数据结构为sequence,表示最终结果非唯一的运算;m-resultlist函数,把每次运算结果转换成一个序列,序列的元素是运算的每个值转成的listm-bind函数继续对序列进行处理,并把结果移出list嵌套。


以上3个要素定义了一个monad,有些monad除了这些必要条件外,还增加了2个额外的定义,以实现特殊功能。这两个函数就是m-zerom-plusm-zero代表一个特殊的monad值,表示当一个运算没有返回值时的返回结果。例如:maybe monad中的nil,通常代表了计算过程失败。另一个例子是sequence monad中的空序列。identity monad 则是不包括m-zero的例子。


m-plus函数把多个计算的结果合并成一个。对sequence monad来说,就是把多个sequence连接成一个;对maybe monad来说,就是返回参数列表中第一个不是nil的参数。


m-zerom-plus需满足的条件:

(= (m-plus m-zero monadic-expression)

   (m-plus monadic-expression m-zero)

   monadic-expression)

一句话,就是组合m-zero和任意的monad表达式,必须返回等价的值。可以从maybesequence中验证这一点。


monad中使用m-zero的好处之一就是可以使用条件分支。在Part1中,我们提及了:when表达式,现在我们就来讨论:

1 (for [a (range 5)
2       :when (odd? a)]
3   (* 2 a))


使用domonad来表示:

1 (domonad sequence
2   [a (range 5)
3    :when (odd? a)]
4   (* 2 a))


domonad宏把let形式的语法,转换成m-bindm-result的组合式,a (range 5) 等价于

1 (m-bind (range 5) (fn [a] remaining-steps))


remaining-steps:when语句被特殊处理

1 (if predicate remaining-steps m-zero)

 


这个例子完全展开后就成为

1 (m-bind (range 5) (fn [a]
2   (if (odd? a) (m-result (* 2 a)) m-zero)))

 


m-bindm-resultm-zero的对应实现替换进来

1 (apply concat (map (fn [a]
2   (if (odd? a) (list (* 2 a)) (list))) (range 5)))

 


map的结果是包含1个或0个元素的list组成的序列:对于奇数的值,返回的是(),即m-zero的值,对于偶数,返回(结果),即m-result的值。concat函数把这些list连接起来成为最后的结果。


对于m-plus,一般跟maybe monadsequence monad一起使用。一个典型的应用是查找(比如一个语法解释器,一个正则搜索,一个数据库查询),可能成功(有返回结果)或失败(无返回结果)。m-plus用于把查找的结果组合返回(sequence monad),或者一直查找,直到找到一个符合条件的值(maybe monad)。原则上来说,使用m-zero比使用m-plus更合适,任何使用m-plus的地方,都可以用m-zero实现。


讲完理论,让我们熟悉几个monad。在本节的开头,我提到monad使用的数据结构并不总是表示运算步骤的结果,有时表示的是运算步骤本身。比如,state monad,它的数据结构是一个函数。


state monad的作用是把状态算法,用纯函数式的方式来实现。状态算法需要更新一些变量的值,在命令式语言中,这很普遍,但是却不符合纯函数式编程的基本原则,因为纯函数式语言是不允许可变数据结构的。一个解决方法是在纯函数语言中使用特殊的数据项(典型的就是Clojuremap)来存储算法所需的可变数据的当前值。在命令式编程中,一个函数可以通过传参,修改变量的当前值,并返回更新后的值。对状态的修改变成显式的数据项,在函数中传递。state monad可以隐藏状态传递的过程,并且写出的算法使用命令式风格来查询和修改状态。


state monad和我们之前看到的monad不同,他的数据结构是一个函数,即运算本身。state monad的值是一个接受一个参数的函数,这个函数就是当前状态的运算。并且返回一个vectorvector长度为2,包括计算结果和更新后的状态。实际上,这些函数都是典型的闭包,并且你在程序中使用的代码和函数,都产生这样的闭包。就像你看到的,state monad允许你组合这些函数,使你的程序看起来跟命令式的一样,尽管他们是纯函数式的。


让我们从一个简单的常见场景开始:你要处理的statemap形式存储。你可能认为map是一个命令式语言中的概念,每个key定义一个变量,两个基本的操作来读取和修改值。在Clojuremonad库中已经提供了这个功能,但不论如何,我这里还是要展示一下(巫云@:如果已经引入了clojure.algo.monads,会发生函数名冲突,可以改名后测试)


首先,我们看fetch-val函数,用于读取一个变量的值:

1 (defn fetch-val [key]
2   (fn [s]
3     [(key s) s]))

 


这里我们定义一个生成state monad值的函数(巫云@:原文为state-monad-value-generating function。它返回一个状态变量s,当执行时,返回一个返回值和新状态组成的vector。返回值是statemap对应的key值。这个函数不改变状态,只是查找。


下面我们来看set-val,返回前一状态的值和包含新状态的map组成的vector

1 (defn set-val [key val]
2   (fn [s]
3     (let [old-val (get s key)
4           new-s   (assoc s key val)]
5       [old-val new-s])))

 


使用这两个元素,我们开始进行组合。我们来定义一个声明,把一个变量的值复制到另一个变量,并返回被修改变量的原值:

1 (defn copy-val [from to]
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的拷贝。让我们试一下:

1 (let [initial-state        {:a 1 :b 2}
2       computation          (copy-val :b :a)
3       [result final-state] (computation initial-state)]
4   final-state)

 

结果为{:a 2, :b 2},正如我们期望的。但是这是如何发生的呢?为了理解state monad,我们需要看一看m-resultm-bind的定义。

m-result没有什么特别,它返回一个函数,根据s,返回smap中的值v,以及未更新过的状态s

1 (defn m-result [v] (fn [s] [v s]))

 

m-bind的实现比较有趣:

1 (defn m-bind [mv f]
2   (fn [s]
3     (let [[v ss] (mv s)]
4       ((f v) ss))))


显然,他返回一个以状态变量s为参数的函数,执行这个函数时,首先对s运行mvm-bind操作链中绑定的第一个声明)。返回值解析到结果v和新的状态ss。第一步的结果v,被后面的操作f使用(跟我们看过的其他m-bind一样)。调用的结果返回另一个state-monad值,它也是一个接受状态变量参数的函数。当我们进入(fn [s] …)时,我们已经处于执行阶段,于是我们必须对状态ss调用这个函数。

(巫云:可以把(domonad state-m
[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 monadClojurelet就是一个identiy monad)中进行了运算。假设你想增加一个运算协议,使用liststring来累加计算过程中的信息,只需要修改write monad对应的identity monad,然后在需要的地方调用write


这里有一个抽象的例子:著名的Fibonacci函数的最直接(同时也是效率最低的)实现:

1 (defn fib [n]
2   (if (< n 2)
3     n
4     (let [n1 (dec n)
5           n2 (dec n1)]
6       (+ (fib n1) (fib n2)))))

 

我们来增加一个运算协议,以便看看,在整个计算过程中,发生了哪些调用。首先,我们重写这个例子,定义每一个计算步骤

1 (defn fib [n]
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

 1 (require ['clojure.contrib.accumulators :as 'accu])
 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 transformersmonad构建组件来定义我们自己的monad。在演示段落,我将解释probability monad,以及如何通过跟maybe-transformer的组合把它应用到贝叶斯(Bayesian)估算。

posted on 2012-03-18 18:44  巫云  阅读(568)  评论(1编辑  收藏  举报

导航