Monad, 原来如此

之前写过一篇关于Monad的文章, Haskell之纯粹, 非纯粹以及Monad, 但是并没有感觉真正理解了Monad, 当然这些思考打下了一些基础, 一些基本的概念, 形式已经了解. 但是对Monad本身感觉还是隔了一层.

这次重温, 结合已有的基础, 得到了新的理解, 自我感觉, 离真相更近了一步.

这次用管道, pipe的概念来做思维模型, 有助于Monad的理解.

很多教程使用了容器来比喻, 例如墨西哥肉卷或者宇航员的宇航服. 不能说没有道理, 但是重点错了, 我之前受了这个误导, 用盒子的思维模型去理解, 结果半懂不懂.

Monad并不像一个封闭的容器或者盒子一样是把数据封装在里面, 密不透风. 更加合适的比喻是管道, 首先它具备容器的特征, 因为数据要经过, 但是管道是两端开口, 进口处和出口处可以做点小动作. 要知道, Monad并不仅仅是封装了数据, 而且还封装了代码和逻辑. 用容器的比喻只表现出了他数据的一面, 代码的一面被忽略了, 而后者才是真正的重点, 第一点反而是最不重要的. 把最重要的一部分丢掉了, 怎么能够理解的完整呢? Monad三个 要素当中只有第一条是关于容器的特征的, 后面两条都是跟逻辑和代码有关.

连接管道, business code

代码分两种, 一种是business code, 就是常说的业务逻辑, 核心业务, 就是一个函数所要干的正事, 或者一个函数的职责. 譬如一个转账的函数, 他的业务逻辑就是完成转账的操作. 还有一类就是cross cutting concern, 比如记录日志, 比如性能统计, 比如事务的开始和commit以及失败情况下的回滚. 这些代码每一个函数都需要, 而且逻辑完全一样, 不依赖具体的业务逻辑.

Business code就是用来连接管道的, 而某些cross cutting concern则被封装到了管道内部, 成为管道的一部分. 而且business code的执行也要取决于管道本身, 作为程序员, 我们只是提供代码, 而代码如何被调用是由管道来决定的. 和我们一般写代码不同, 在一般的语言中, 我们写一个函数, 同时也要写调用他的代码. 写连接Monad的代码更像是写插件. 这也是一种控制反转, 就像很多IoC容器一样, 只提供代码, 而不负责调用和weaving.

Maybe monad

下面以Maybe monad为例来说明如何用管道来理解之.

 
return::a -> Maybe a
return x = Just x
 
(>>=)::Maybe a -> (a -> Maybe b) -> Maybe b
m >>= g = case m of
  Nothing -> Nothing
  Just x -> g x
 

我们来定义几个简单的business code, 简单起见, 假设有三个函数f, g, h, 他们都接受整数并做一些计算, 例如加2, 乘3, 除以6等等.

现在我们要将他们monad化, 包括两个部分, 一个是值的monad化, int变成 Maybe Int

第二个是代码的monad化, 在monad管道之间流通的类型始终是monad, 因此函数的类型要从Int -> Int 变成 Int -> Maybe Int

Monad化之后的business code记为f', g', h'.其实就是在执行f, g, h之后把结果结果Monad化, 因此

 
g' = return . g 

用Clojure来表示更加清晰

 
(defn g' [x]
  (return (g x))
)
 

现在来构建一个管道

 
m >>= g' >>= h' >>= f'

有monad和没有monad的情况下计算的过程:

 
value -> g -> value -> h -> value -> f -> value
monad value -> g' monad value -> h' monad value -> f' monad value
 

请注意观察我们的business code是如何参与计算的. 只有一个地方即bind的第二个条件满足的时候

 
  Just x -> (return (g x ))
 

设想Maybe monad是这样一种管道, 管道内部有两条车道, 入口处有检查站, 出口处有收费站, 入口处如果Monad是Nothing则走第一车道, 如果是Just x则走第二车道. 出口处, 一车道畅通无阻, Nothing直接进入下一个管道, 二车道会把x提取出来, 输入我们的business code, business code产生的结果再包装为Monad, 然后才往下一个管道.

Haskell标记方法强化了这种印象, 如果我们将这段代码变成Clojure代码:

 
(>>=
  (>>=
    (>>= m g')
    h')
  f')
 

所以Monad就是一种continuation, 好像把一个布袋外翻过来, 里面变成外面, 外面变成里面.

在正常的函数式代码中, 最外层是最后计算的, 最内层是最先计算的. 如果我们从最外层开始写起, 那就是top down式的设计, 其实是不符合实际思维模式的, 当然我们可以先写最底层的函数, 然后逐渐增加外围的东西, 但是从阅读的角度终究不方便. 从执行的角度, 外层函数最开始执行, 碰到更加底层的函数的时候, 再调用他们, 并用他们的返回值继续计算.

Monad把这个过程反过来了, 先计算最内层的函数, 然后由内层的函数调用需要使用他的外层函数并把结果传递给他. 也就是说他不是被调用, 而是他自己主动调用他的caller并把结果传递给caller. 一个函数本身的计算就是当前的计算, 而后面的计算叫做rest of computation, 这是一个计算如何继续下去的问题, Monad中, 函数自己决定rest of computation应该如何继续下去, 这里提供了可操作的空间. 例如检查NULL值, 等等.