Haskell初心—认识Monad

从三月份开始看Real World Haskell这本书,断断续续看到7月份,总算初步对Haskell有了一些认识。
我想,学习Haskell这门语言,第一个门槛就是Monad这个概念。今天初步来做一点总结。

在读书和试着用haskell做一些习题的时候,就会感觉到,haskell是一门实践性极强的语言。当然,他同时也是一门理论性极强的语言,他允许甚至鼓励你用数学方法去推导函数签名,通过定义一系列公理可以对特定类型的函数式进行数学变形,以达到最短最优美的代码形式。

但对于我而言,那些过分抽象的函数和概念,非常难以理解,并且即使是形式上理解了他的定义,还是无法理解他为何要如此定义,有什么意义。Haskell代码的特点在于极端的精炼,许多函数定义看上去什么都没做,像是在说废话。幸好RWH是一本非常好的教材,他通过许多实例来说明这些抽象定义在实践中的用途,让你看到许许多多在实际代码中经常遇到的痛点,在Haskell中都有解药。如同醍醐灌顶般爽快。(请参见代码交叉拷贝悖论

Monad就是Haskell的一个经典设计。它来源于“范畴论”,可以说是数学中的数学,一坨“抽象废话”。我先不去考虑他的数学含义,单看他在代码中如何化繁为简。这里我抄一段RWH中的代码(略简化)。

data MovieReview = MovieReview {
      revTitle :: String
    , revUser :: String
    , revReview :: String
}

simpleReview :: [(String, String)] -> Maybe MovieReview
simpleReview alist = 
    case lookup "title" alist of
      Just title@(_:_) ->
          case lookup "user" alist of
            Just user@(_:_) ->
                case lookup "review" alist of
                  Just review@(_:_) ->
                      Just (MovieReview title user review)
                  _ -> Nothing         -- no review
            _ -> Nothing               -- no user
      _ -> Nothing                     -- no title

这一段代码是说,有一个电影影评的类型MovieReview,包含revTitle,revUser,revReview三个字段。现在用一个association list来对他进行初始化,simpleReview函数中的case语句略似if else,我们可以看到这段代码的大意是说如果alist中含有title、user、review三个键值并且对应的字段不为空,则用这三个字段初始化MoviewReview类型,否则依次返回Nothing (Maybe是一个包装类型,它的值可以是对应类型的值,或者是Nothing)。
这里是采取了最传统的方式,展现了写代码时经常遇到的苦恼。三层if判断,里面的操作很雷同,应该有办法抽象出来。在C语言中我会采取的办法是把title, user, review三个键值存在数组里,并且通过一个循环来依次判断,虽然实现了重用代码,但是代码可读性变差了。代码主体内充满了类似

auto moviereview = new MovieReview (alist[keylist[0]], 
                                    alist[keylist[1]], 
                                    alist[keylist[2]]);


这样的代码。我们来看看Haskell如何通过monad实现对这样的代码的抽象。实际上Maybe类型就是一个Monad。一个Monad可以简单理解为一个包装类型,他包装了一个变量(可能是函数、操作、状态等等),并且提供一个“>>=”运算,这个运算的意思是,把它包装的变量提取出来,当作参数传入一个可以接受这种类型参数的函数中,而这个函数的返回值必须也是monad包装的。
另外Monad还需提供一个return函数,方便其他函数将原始类型包装为Monad类型。

class Monad m where
  -- chain
  (>>=) :: m a->(a -> m b) -> m b
  -- inject
  return :: a -> m a


为何要这样定义>>=运算,一个monad >>=运算将一个Monad (m a)中的变量(a)扔进一个(a -> m b)函数中,生成一个新的Monad,它包含的变量类型跟传入的可能不同(m b)。这个m b还是Monad,就又可以继续通过>>=方法扔进新的(a -> m b)中。并且这里特别将输入类型m a和输出类型m b分别表示,从而可以将许多不同的方法通过>>=连接起来。就好象一个Monad被送上了生产线,经过一道道工序加工它本身的类型也可以经过许多变化,最终输出一个m b。

这个定义非常优美,我相信c++中的输入输出流操作符就是从这里借鉴的。不过这个流可不仅仅能做输入输出的操作,它可以控制任何一种操作流,非常神奇。因此在RWH中称>>=算符是一种可编程的分号 每个操作后面用>>=结尾可以对应于命令式语言中的;分号,在haskell这样的函数式语言中,通过monad实现了命令式的编程范式。更加强大的是,他的可扩展性远高于传统的命令依次执行,具有灵活的可操控性。

Maybe就是一种monad。他的>>=方法将其包装的变量提取出来,给后续的函数作为参数。如果为Nothing则短路,直接返回Nothing

(>>=) :: Maybe a ->(a -> Maybe b) -> Maybe b
Nothing >>= _ = Nothing
Just v >>= f = f v


通过这一层包装可以很好的将前面的一段代码简化,依次处理title, user, review三个字段,通过>>=这个“可编程的分号”来控制操作流。如果任何一个字段出现Nothing,则后续的>>=函数都会返回Nothing,不会继续调用lookup函数。

simpleReview alist = 
  lookup "title" alist >>=
  \title -> lookup "user" alist >>=
  \user -> lookup "review" alist >>= 
  \review -> Just (MovieReview title user review)  


可以看到代码已经充分简化了。但Haskell的设计者还是不满意,这样的代码还有重复之罪。如果我们可以直接将(lookup “string” alist)作为参数,放进MovieReview的构造函数的话,就可以一行代码实现这个任务了。现在遇到的困难是,lookup返回包裹在Maybe中的量:可能找得到,可能找不到。而MovieReview的构造函数,仅当三个查找都不为空的时候才应当调用,并且其参数应当是解除包装的字符串类型。如何将Monad包裹的值当作参数传入普通的Pure function中呢?

这里Haskell引入了Functor的概念:

class Functor f where
    fmap :: (a->b) -> f a -> f b


他将一个普通的函数(a -> b)提升为对包装类进行操作的Functor (f a -> f b)。这里f是包装函数。这样使得任何包装类型都可以轻松继承所有pure function的代码。在Monad中定义了这样的一个提升操作,称之为liftM

liftM :: (Monad m) => (a -> b) -> m a -> m b
liftM f m = m >>= \i -> return (f i)


这样任何一个f都可以对Monad中包装的值通过“提升操作”进行调用了。这里的liftM仅针对单参函数(a -> b)。我们的MovieReview需要3个参数,这里我们可以借用haskell函数库中的liftM3,针对3参数函数进行提升。

liftM3 :: (Monad m) => (a -> b -> c -> d) -> m a -> m b -> m c -> m d
liftM3 f m1 m2 m3 =
    m1 >>= \a ->
    m2 >>= \b ->
    m3 >>= \c ->
    return (f a b c)

那我们的代码又可以进一步简化了

liftReview alist =
    liftM3 MovieReview (lookup "title" alist)
                       (lookup "user" alist)
                       (lookup "review" alist)


看,经过提升之后MovieReview就像是可以接受三个Maybe作为参数一样地进行调用了,并且>>=符隐藏在了liftM3中,其中控制了一旦任意一个maybe为nothing的时候短路这个流程。虽然这样client代码已经非常优美了,但是lib代码会有些问题。这里的liftM3已经有些呆板了。如果是要提升10个参数的函数怎么办。精益求精,Haskell提供了ap函数让提升操作可以链状调用。

ap :: (Monad m) => m (a -> b) -> m a -> m b
ap  =  liftM2 id


从函数类型可以看出,他要求将pure function (a -> b)包装到monad中。这样做可以进行链式调用。可是为何通过这样的链式调用就能将多参函数链式提升了呢?这是因为实际上haskell仅支持单参函数。例如(a -> b -> c -> d)这个类型,他表示接受(a, b, c)三个参数,并且返回d类型的函数。这个函数实际上是接受a类型的参数,并且返回一个(b-> c -> d)类型的函数的函数。这有点像是通过接受了参数a来绑定了三个参数中的一个,生成了一个偏特化的函数(partial function)。
ap函数类似是通过提升将这个多参函数的第一个参数提升了,返回用monad包装的偏特化函数(m b)。这个新的被monad包装的函数可以继续传入ap,继续偏特化下一个参数。因此这个操作可以链式提升多参函数的每一个参数,最终完成提升过程。这里ap的名字意思是“apply”,就能清楚他的意义了。
ap实际上通过liftM2 id实现。id是返回本身

id a = a


而liftM2提升两个参数并返回调用f的包装结果,现在f是id

liftM2 :: (Monad m) => (a1 -> a2 -> r) -> m a1 -> m a2 -> m r
liftM2 f m1 m2 =
    m1 >>= \a1 ->
    m2 >>= \a2 ->
    return (f a1 a2)

liftM2 id = 
    m1 >>= \a1 ->
    m2 >>= \a2 ->
    return (a1 a2)   -- id means self


这里有个不太符合直觉的地方,return (a1 a2)就是m (a1 a2), 他怎么就变成ap的m (a -> b) -> m a -> m b了?
这要看传入的参数。ap接受两个参数分别是m (a -> b)和 m a。用这两个参数替换liftM2的a1和a2后

liftM2 id (m (a -> b)) (m a) = 
    m (a -> b) >>= \(a -> b) ->
    m a >>= \a ->
    return ( (a -> b)  a)


经过这个推导,可以清晰地看出,liftM2 id将ap的第一个参数m (a -> b)提升后绑定了第一个参数a,也就是ap的第二个参数m a。并且返回了绑定参数后的偏特化函数b的包装monad, m b。这个绑定的过程可以在最后一句体现出来:return ( ( a -> b ) a)。对(a->b)应用参数a。
从这里可以看出monad是一个极端抽象的概念,讨论他经常会遇到不易理解的地方,只有带上实际使用的参数后才能容易理解。

利用ap函数的代码如下,这里使用`符号包裹一个双参数的函数,将函数当作操作符来使用,从而得到链式调用的代码

liftReview alist =
    MovieReview `liftM` lookup "title" alist
                   `ap` lookup "user" alist
                   `ap` lookup "review" alist

好了,做个小结。我刚学haskell不久,对monad有了一个初步的了解。可以看到monad是一个实践性非常强的抽象,涉及到的lift, functor等概念,如果只是形式上地看他的设计,很难理解设计的意图,就像是一坨抽象废话。但是一旦从实践中多利用这些工具,就能轻松的避免各种各样的代码坏味,得到美观自然和可读性非常强的代码。Haskell这样的语言,只要用心设计好函数和变量名,即使不写注释也能明白代码的意图,并且很难出错。因为他把实现隐藏起来,代码就是直接表达了意图。我认为这是非常有潜力的语言,今后会做进一步的学习和研究,并尽快将其用于实战。

4 comments on “Haskell初心—认识Monad

  • Hello. 我是Bing,上次在vickers event碰到的new intern. 这个blog我表示很高级看不懂。哈哈,不过很高兴认识你们了。

  • 好文。不过中间的lookup应该改为RWH中lookup1:

    否则类型不匹配

Comments are closed.