您的位置:首页 > 其它

Monad_Haskell笔记10

2021-01-14 21:02 183 查看

一.从Functor到Monad
从类型来看,Functor到Applicative再到Monad是从一般到特殊的递进过程(Monad是特殊的Applicative,Applicative是特殊的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)
Just2

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里的值)而言,拥有Functor、Applicative和Monad已经足够应付所有情况了

**二.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而已

return是pure的别名,所以仍然是接受一个普通值并把它放进一个最小的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是说,对于任意的类型变量a和b,>>=函数的类型是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**
Maybe的Monad实现相当符合直觉:

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),没出错的话就能得到正确的结果

用Maybe的Monad特性来描述:

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?那要怎么返回多个值?

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

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

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

>>看起来有点意思,等价于定义在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条,所以是标准的Monoid,Moand m => a -> m b函数集合及定义在其上的>=>运算构成幺半群,幺元是return

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

**MonadPlus**
同时满足Monad和Monoid的东西有专用的名字,叫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

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: