Monad_Haskell笔记10

一.从Functor到Monad

从类型来看,FunctorApplicative再到Monad是从一般到特殊的递进过程(Monad是特殊的ApplicativeApplicative是特殊的Functor

Functor

能够把普通函数map over到一个具有context的值

fmap :: (Functor f) => (a -> b) -> f a -> f b

用来解决context相关计算中最简单的场景:怎样把一个不具context的函数应用到具有context的值?

(+1) ->? Just 1

fmap登场:

> fmap (+1) (Just 1)
Just 2

Applicative

Functor之上的增强,能够把context里的函数map over到一个具有context的值

(<*>) :: (Applicative f) => f (a -> b) -> f a -> f b
pure :: (Applicative f) => a -> f a

Applicative可以理解为计算语境(computation context),Applicative值就是计算,比如:

Maybe a代表可能会失败的computation,[a]代表同时有好多结果的computation(non-deterministic computation),而IO a代表会有side-effects的computation。

P.S.关于computation context的详细信息,见Functor与Applicative_Haskell笔记7

用来解决context相关计算中的另一个场景:怎样把一个具有context的函数应用到具有context的值?

Just (+1) ->? Just 1

<*>登场:

> Just (+1) <*> (Just 1)
Just 2

Monad

Applicative之上的增强,能够把一个输入普通值输出具有context值的函数,应用到一个具有context的值

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

如果你有一个具有context的值m a,你能如何把他丢进一个只接受普通值a的函数中,并回传一个具有context的值?也就是说,你如何套用一个型态为a -> m b的函数至m a

用来解决context相关计算中的最后一个场景:怎样把一个输入普通值输出具有context的值的函数,应用到具有context的值?

\x -> Just (x + 1) ->? Just 1

>>=登场:

> Just 1 >>= \x -> Just (x + 1)
Just 2

三者的关联

从接口行为来看,这三个东西都是围绕具有context的值和函数在搞事情(即,context相关的计算)。那么,考虑一下,共有几种组合情况?

  • 函数输入输出类型一致的情况

    • context里的函数 + context里的值:Applicative

    • context里的函数 + 普通值:用pure包一下再调

    • 普通函数 + context里的值:Functor

    • 普通函数 + 普通值:函数调用

  • 函数输入输出类型不一致的情况

    • 函数输入普通值,输出context里的值 + context里的值:Monad

    • 函数输入普通值,输出context里的值 + 普通值:直接调用

    • 函数输入context里的值,输出普通值 + context里的值:直接调用

    • 函数输入context里的值,输出普通值 + 普通值:用pure包一下再调

所以,就这个场景(把是否处于context里的函数应用到是否处于context里的值)而言,拥有FunctorApplicativeMonad已经足够应付所有情况了

二.Monad typeclass

class Applicative m => Monad m where
  (>>=)       :: forall a b. m a -> (a -> m b) -> m b
  (>>)        :: forall a b. m a -> m b -> m b
  m >> k = m >>= \_ -> k
  return      :: a -> m a
  return      = pure
  fail        :: String -> m a
  fail s      = errorWithoutStackTrace s

实际上,Monad实例只要求实现>>=函数(称之为bind)即可。换言之,Monad就是支持>>=操作的Applicative functor而已

returnpure的别名,所以仍然是接受一个普通值并把它放进一个最小的context中(把普通值包进一个Monad里面)

(>>) :: m a -> m b -> m b定义了默认实现,把函数\_ -> m b通过>>=应用到m a上,用于(链式操作中)忽略前面的计算结果

P.S.链式操作中,把遇到的>>换成>>= \_ ->就很容易弄明白了

P.S.上面类型声明中的forall是指(离散数学中的量词,全称量词表示“任意”,存在量词表示“存在”)。所以forall a b. m a -> (a -> m b) -> m b是说,对于任意的类型变量ab>>=函数的类型是m a -> (a -> m b) -> m b。可以省略掉forall a b.,因为默认所有的小写字母类型参数都是任意的:

In Haskell, any introduction of a lowercase type parameter implicitly begins with a forall keyword

三.Maybe Monad

MaybeMonad实现相当符合直觉:

instance  Monad Maybe  where
  (Just x) >>= k      = k x
  Nothing  >>= _      = Nothing
  fail _              = Nothing

>>=把函数k应用到Just里的值上,并返回结果,Nothing的话,就直接返回Nothing。例如:

> Just 3 >>= \x -> return (x + 1)
Just 4
> Nothing >>= \x -> return (x + 1)
Nothing

P.S.注意我们提供的函数\x -> return (x + 1)return的价值体现出来了,要求函数类型是a -> m b,所以把结果用return包起来很方便,并且语义也很恰当

这种特性很适合处理一连串可能出错的操作的场景,比如JS的:

const err = error => NaN;
new Promise((resolve, reject) => {
  resolve(1)
})
.then(v => v + 1, err)
.then(v => {throw v}, err)
.then(v => v * 2, err)
.then(console.log.bind(this), err)

一连串的操作,中间步骤可能出错(throw v),出错后得到表示错误的结果(上例中是NaN),没出错的话就能得到正确的结果

MaybeMonad特性来描述:

> return 1 >>= \x -> return (x + 1) >>= \_ -> (fail "NaN" :: Maybe a) >>= \x -> return (x * 2)
Nothing

1:1完美还原,利用Maybe Monad从容应对一连串可能出错的操作

四.do表示法

在I/O场景用到过do语句块(称之为do-notation),可以把一串I/O Action组合起来,例如:

> do line <- getLine; char <- getChar; return (line ++ [char])
hoho
!"hoho!"

把3个I/O Action串起来,并返回了最后一个I/O Action。实际上,do表示法不仅能用于I/O场景,还适用于任何Monad

就语法而言,do表示法要求每一行都必须是一个monadic value,为什么呢?

因为do表示法只是>>=的语法糖,例如:

foo = do
  x <- Just 3
  y <- Just "!"
  Just (show x ++ y)

类比不涉及context的普通计算:

let x = 3; y = "!" in show x ++ y

不难发现do表示法的清爽简洁优势,实际上是:

foo' = Just 3   >>= (\x ->
  Just "!" >>= (\y ->
  Just (show x ++ y)))

如果没有do表示法,就要手动写一堆lambda嵌套:

Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))

所以<-的作用是:

像是使用>>=来将monadic value带给lambda一样

>>=有了,那>>呢,怎么用?

maybeNothing :: Maybe Int
maybeNothing = do
  start <- return 0
  first <- return ((+1) start)
  Nothing
  second <- return ((+2) first)
  return ((+3) second)

当我们在do表示法写了一行运算,但没有用到<-来绑定值的话,其实实际上就是用了>>,他会忽略掉计算的结果。我们只是要让他们有序,而不是要他们的结果,而且他比写成_ <- Nothing要来得漂亮的多。

最后,还有fail,do表示法中发生错误时会自动调用fail函数:

fail        :: String -> m a
fail s      = errorWithoutStackTrace s

默认会报错,让程序挂掉,具体Monad实例有自己的实现,比如Maybe

fail _              = Nothing

忽略错误消息,并返回Nothing。试玩一下:

> do (x:xs) <- Just ""; y <- Just "abc"; return y;
Nothing

do语句块中模式匹配失败,直接返回fail,意义在于:

这样模式匹配的失败只会限制在我们monad的context中,而不是整个程序的失败

五.List Monad

instance Monad []  where
  xs >>= f             = [y | x <- xs, y <- f x]
  (>>) = (*>)
  fail _              = []

List的context指的是一个不确定的环境(non-determinism),即存在多个结果,比如[1, 2]有两个结果(1,2),[1, 2] >>= \x -> [x..x + 2]就有6个结果(1,2,3,2,3,4

P.S.怎么理解“多个结果”?

初学C语言时有个困惑,函数能不能有多个return?那要怎么返回多个值?

可以返回一个数组(或者结构体、链表等都行),把多个值组织到一起(放进一个数据结构),打包返回

如果一个函数返回个数组,就不确定他返回了多少个结果,这就是所谓的不确定的环境

ListMonad实现来看,>>=是个映射操作,没什么好说的

>>看起来有点意思,等价于定义在Applicative上的*>

class Functor f => Applicative f where
  (*>) :: f a -> f b -> f b
  a1 *> a2 = (id <$ a1) <*> a2

class  Functor f  where
  (<$)        :: a -> f b -> f a
  (<$)        =  fmap . const

const                   :: a -> b -> a
const x _               =  x

作用是丢弃第一个参数中的值,仅保留结构含义(List长度信息),例如:

> [1, 2] >> [3, 4, 5]
[3,4,5,3,4,5]

等价于:

> ((fmap . const) id $ [1, 2]) <*> [3, 4, 5]
[3,4,5,3,4,5]
-- 或者
> [id, id] <*> [3, 4, 5]
[3,4,5,3,4,5]

List Comprehension与do表示法

一个有趣的示例:

> [1,2] >>= \n -> ['a','b'] >>= \ch -> return (n,ch)
[(1,'a'),(1,'b'),(2,'a'),(2,'b')]

最后的n看着不太科学(看infixl 1 >>=好像访问不到),实际上能访问到n,是因为lambda表达式的贪婪匹配特性,相当于:

[1,2] >>= \n -> (['a','b'] >>= \ch -> return (n,ch))
-- 加括号完整版
([1, 2] >>= (\n -> (['a','b'] >>= (\ch -> return (n,ch)))))

函数体没界限就匹配到最右端,相关讨论见Haskell Precedence: Lambda and operator

P.S.另外,如果不确定表达式的结合方式(不知道怎么加括号)的话,有神奇的方法,见How to automatically parenthesize arbitrary haskell expressions?

do表示法重写:

listOfTuples = do
  n <- [1,2]
  ch <- ['a','b']
  return (n,ch)

形式上与List Comprehension很像:

[ (n,ch) | n <- [1,2], ch <- ['a','b'] ]

实际上,List Comprehension和do表示法都只是语法糖,最后都会转换成>>=进行计算

六.Monad laws

同样,Monad也需要遵循一些规则:

  • 左单位元(Left identity):return a >>= f ≡ f a

  • 右单位元(Right identity):m >>= return ≡ m

  • 结合律(Associativity):(m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)

单位元的性质看起来不很明显,可以借助Kleisli composition转换成更标准的形式:

-- | Left-to-right Kleisli composition of monads.
(>=>)       :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g     = \x -> f x >>= g

(摘自Control.Monad

从类型声明来看,>=>相当于Monad函数之间的组合运算(monadic function),这些函数输入普通值,输出monadic值。类比普通函数组合:

(.)    :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)

>=>从左向右组合Moand m => a -> m b的函数,.从右向左组合a -> b的函数

P.S.那么,有没有从右向左的Monad函数组合呢?没错,就是<=<

用Kleisli composition(>=>)来描述Monad laws:

  • 左单位元:return >=> f ≡ f

  • 右单位元:f >=> return ≡ f

  • 结合律:(f >=> g) >=> h ≡ f >=> (g >=> h)

满足这3条,所以是标准的MonoidMoand m => a -> m b函数集合及定义在其上的>=>运算构成幺半群,幺元是return

P.S.用>=>描述的Monad laws,更大的意义在于这3条是形成数学范畴所必须的规律,从此具有范畴的数学意义,具体见Category theory

MonadPlus

同时满足MonadMonoid的东西有专用的名字,叫MonadPlus

class (Alternative m, Monad m) => MonadPlus m where
  mzero :: m a
  mzero = empty
  mplus :: m a -> m a -> m a
  mplus = (<|>)

在List的场景,mzero就是[]mplus++

instance Alternative [] where
  empty = []
  (<|>) = (++)

这有什么用呢?

比如要对列表元素进行过滤的话,List Comprehension最简单:

> [ x | x <- [1..50], '7' `elem` show x ]
[7,17,27,37,47]

>>=也能搞定:

> [1..50] >>= \x -> if ('7' `elem` show x) then [x] else []
[7,17,27,37,47]

条件表达式看起来有些臃肿,有了MonadPlus就可以换成更简洁有力的表达方式

> [1..50] >>= \x -> guard ('7' `elem` show x) >> return x
[7,17,27,37,47]

其中guard函数如下:

guard           :: (Alternative f) => Bool -> f ()
guard True      =  pure ()
guard False     =  empty

输入布尔值,输出具有context的值(True对应放在缺省context里的()False对应mzero

guard处理后,再利用>>把非幺元值恢复成原值(return x),而幺元经过>>运算后还是幺元([]),就被滤掉了

对应的do表示法如下:

sevensOnly = do
  x <- [1..50]
  guard ('7' `elem` show x)
  return x

对比List Comprehension形式:

[ x | x <- [1..50], '7' `elem` show x ]

非常相像,都是几乎没有多余标点的简练表达

在do表示法中的作用

把Monad laws换成do表示法描述的话,就能得到另一组等价转换规则:

-- Left identity
do { x′ <- return x;
    f x′
  }
≡
do { f x }

--  Right identity
do { x <- m;
    return x
  }
≡
do { m }

-- Associativity
do { y <- do { x <- m;
              f x
            }
    g y
  }
≡
do { x <- m;
    do { y <- f x;
          g y
        }
  }
≡
do { x <- m;
    y <- f x;
    g y
  }

这些规则有2个作用

  • 能够用来简化代码

    skip_and_get = do
      unused <- getLine
      line <- getLine
      return line
    
    -- 利用Right identity,去掉多余的return
    skip_and_get = do
      unused <- getLine
      getLine
    
  • 能够避免do block嵌套

    main = do
      answer <- skip_and_get
      putStrLn answer
    
    -- 展开
    main = do
      answer <- do
        unused <- getLine
        getLine
      putStrLn answer
    
    -- 用结合律解开do block嵌套
    main = do
      unused <- getLine
      answer <- getLine
      putStrLn answer
    

七.Monad与Applicative

回到最初的场景,我们已经知道了Monad在语法上能够简化context相关计算,能够把a -> m b应用到m a

既然Monad建立在Applicative的基础之上,那么,与Applicative相比,Monad的核心优势在哪里,凭什么存在?

因为applicative functor并不允许applicative value之间有弹性的交互

这,怎么理解?

再看一个Maybe Applicative的示例:

> Just (+1) <*> (Just (+2) <*> (Just (+3) <*> Just 0))
Just 6

中间环节都不出错的Applicative运算,能够正常得到结果。如果中间环节出错了呢?

-- 中间失败
> Just (+1) <*> (Nothing <*> (Just (+3) <*> Just 0))
Nothing

也符合预期,纯Applicative运算似乎已经满足需要了。仔细看看刚才是如何表达中间环节的失败的:Nothing <*> some thing。这个Nothing就像是硬编码装上去的炸弹,是个纯静态场景

那想要动态爆炸的话,怎么办?

-- 灵活性不足
> Just (+1) <*> (Just (\x -> if (x > 1) then Nothing else return (x + 2)) <*> (Just (+3) <*> Just 0))
<interactive>:85:1: error:
• Non type-variable argument in the constraint: Num (Maybe a)
  (Use FlexibleContexts to permit this)
• When checking the inferred type
    it :: forall a. (Ord a, Num (Maybe a), Num a) => Maybe (Maybe a)

出错的原因是试图动态控制爆炸,却搞出来一个Maybe (Maybe a)

> Just (\x -> if (x > 1) then Nothing else return (x + 2)) <*> (Just (+3) <*> Just 0)
Just Nothing

之所以会出现这样尴尬的局面,是因为Applicative<*>只是机械地从左侧context里取出函数,应用到右侧context里的值上。从Maybe取函数只有两种结果:要么从Nothing取不出东西来,立即爆炸;要么从Just f取出个f,运算得到Just (f x),上一步(x)没炸的话就炸不了了

所以,从应用场景来看,Monad是一种计算语境控制,应对一些通用场景,比如错误处理,I/O,不确定结果数量的计算等等,其存在意义是:比Applicative更灵活,允许在每一步计算中添加控制,像Linux管道一样

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code