专栏首页黯羽轻扬Monad_Haskell笔记10

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) <*> a2class  Functor f  where
 (<$)        :: a -> f b -> f a
 (<$)        =  fmap . constconst                   :: 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管道一样

参考资料

  • Monad
  • The forall keyword
  • Monad laws
  • Explanation of Monad laws

本文分享自微信公众号 - 前端向后(backward-fe),作者:黯羽轻扬

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-06-23

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 函数_TypeScript笔记5

    带类型的函数声明足够表达一个函数的类型信息,但无法复用。那么有办法复用一个函数的类型吗?

    ayqy贾杰
  • 让Monad来得更猛烈些吧_Haskell笔记11

    最早接触过IO Monad,后来又了解了Maybe Monad和List Monad,实际上还有很多Monad(比如Writer Monad、Reader Mo...

    ayqy贾杰
  • Monadic Function_Haskell笔记12

    只是把context换成了Monad而已,此外没什么区别。并且对于遵守Functor laws和Monad laws的类型,这两个函数是完全等价的,例如:

    ayqy贾杰
  • asp.net mvc 简单项目框架的搭建过程(一)对Bll层和Dal层进行充分解耦

      学习asp.net 已经有近三个月的时间了,在asp.net mvc上花的时间最多,但个人真是有些菜,不得不说,asp.net mvc的水真的还是蛮深的。目...

    CherishTheYouth
  • dotnet 用 NuGet 将自己的工具作为 dotnet tool 分发

    我写了一个有趣的工具,我如何将这个工具给到小伙伴予力众生呢?只需要设定这个工具是 dotnet tool 工具就可以通过 NuGet 分发出去啦。几乎所有的 d...

    林德熙
  • LeetCode 1317. 将整数转换为两个无零整数的和

    Michael阿明
  • 【Leetcode】107. 二叉树的层次遍历 II

    给定一个二叉树,返回其节点值自底向上的层次遍历。 (即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)

    Leetcode名企之路
  • LeetCode 1

    来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/play-with-chips

    范中豪
  • 漫画:“浅谈”二分查找

    也叫折半排序,这个方法查询比较高效,时间复杂度为O(log2n),不过他有一个要求,要是有序的顺序的存储结构表。

    用户5224393
  • Golang实现Fibonacii的几种算法

    package fib /** Fibonacii的几种算法实现 */ // 直接循环计算 func Fib(n int) int { f := [...

    李海彬

扫码关注云+社区

领取腾讯云代金券