内容简介:在上一篇文章中,我通过几个Java的例子简单的说明了Monad的本质和一些工程中常见的用途。接下来的文章就不再侧重于工程了,而是要慢慢向理论转换。而作为过渡,我选择了Haskell来代替Java进行说明。本篇文章默认读者已经对Haskell的基本语法有所了解,因此对此类内容我不会再做赘述。我们先来改写之前的Monad:然后我们来实现它的
Welcome to Haskell
在上一篇文章中,我通过几个 Java 的例子简单的说明了Monad的本质和一些工程中常见的用途。接下来的文章就不再侧重于工程了,而是要慢慢向理论转换。而作为过渡,我选择了Haskell来代替Java进行说明。本篇文章默认读者已经对Haskell的基本语法有所了解,因此对此类内容我不会再做赘述。
我们先来改写之前的Monad: Optional
和 List
。先来看 Optional
,由于它只有两种“状态”,因此在Haskell中可以这么表示
data Optional a = Value a | Empty deriving Show
然后我们来实现它的 fmap
函数
omap :: (a -> b) -> Optional a -> Optional b omap f (Value a) = Value (f a) omap f Empty = Empty omap (+3) (Value 4) -- Value 7
对于列表的实现也是类似的。不过由于列表可以是任意长的,因此需要定义一个链状的结构
data List a = Nil | Cons a (List a) infixr 5 `Cons`
在Haskell中,用 `
包裹的函数可以作为中缀函数使用(比如加法等等),而 infixr
规定了 Cons`
是右结合的。所以对于列表 [ 1, 2, 3 ]
,用 List
表示就是 1 `Cons` 2 `Cons` 3 `Cons` Nil
,等同于 Cons 1 (Cons 2 (Cons 3 Nil))
。如果你还是无法理解这个列表,不妨把这种形式想象成链表: Cons
的第一个参数就是当前结点的值,第二个参数就是下一个结点;列表的最后总是连接尾结点 Nil
。
对于列表, fmap
的作用就是遍历每一个列表元素,并对它们应用传入的函数 f
。通过模式匹配和递归,不难写出对应的 lmap
lmap :: (a -> b) -> List a -> List b lmap _ Nil = Nil lmap f (Cons x xs) = f x `Cons` lmap f xs
为了便于调试,可以给 List
实现 Show
,这样就可以打印出比较可读的列表了。
ljoin :: String -> List String -> String ljoin _ Nil = "" ljoin _ (Cons x Nil) = x ljoin mid (Cons x xs) = x ++ mid ++ ljoin mid xs instance (Show a) => Show (List a) where show Nil = "[]" show xs = "[ " ++ ljoin ", " (lmap show xs) ++ " ]" 1 `Cons` 2 `Cons` Nil -- [ 1, 2 ]
Functor的实现
Haskell使用Typeclass来描述Functor,对应于Java中的接口,不过表达能力要更强。标准库对Functor的定义如下:
class Functor f where fmap :: (a -> b) -> f a -> f b
没有具体定义的 fmap
就是我们需要实现的函数。使用 instance
可以将之前声明的 Optional
定义为Functor。
instance Functor Optional where fmap = omap
同样,列表也可以这样声明为Functor
instance Functor List where fmap = lmap
Applicative
但是我们没法直接声明 Monad
的instance,因为在Haskell中, Functor
与 Monad
之间还有一个 Applicative
。那么Appliacative是什么呢?Applicative是对“应用”的抽象,它允许在容器中“存放”一个函数。
还是用例子来说明。上一篇文章的最后,我举了一个多参函数的例子。当时我们封装了一个函数 liftM2
用来处理2参数的函数。但是如果按照这个方法,我们对每一个数量的参数都需要写一个 liftM*
函数,非常麻烦。而对于容器外面的普通函数,我们就不会遇到这个问题,因为函数都是柯里化的。所以,为什么不把柯里化引入Functor呢?换言之,就是要允许在Functor中“存放”函数,而这个Functor就是Applicative。
为了把函数放进Functor,我们需要考察函数的性质。对于函数来说,最重要的就是“应用”。因此,Applicative有别于Functor的重要一点就是要求实现表达“应用”的函数。Haskell是这么表达这个函数的
(<*>) :: f (a -> b) -> f a -> f b
好吧,它的名字确实有一点怪。Haskell中全符号的、被小括号包裹的函数默认是中缀的,比如这个函数的调用就是中缀形式 f <*> xs
。 <*>
接受一个容器内的函数和值,并将运算之后的结果重新放在容器中。比如
(Value (+3)) <*> Value 2 -- Value 5
同样,柯里化的操作也是OK的
(Value (*)) <*> Value 2 <*> Value 3 -- Value 6
此外,为了将函数“装”进容器,还需要一个包装函数的函数。在Haskell中是这么表示的
pure :: a -> f a
因此就可以如此表示了
pure (*) <*> Value 2 <*> Value 3
总结一下,就可以得到Haskell对Applicative的定义了
class Functor f => Applicative f where pure :: a -> f a (<*>) :: f (a -> b) -> f a -> f b
实现Applicative
实现Applicative的方法和 fmap
大同小异,唯一的区别就是还需要对函数进行模式匹配。
instance Applicative Optional where pure = Value Empty <*> _ = Empty _ <*> Empty = Empty (Value f) <*> (Value x) = Value (f x)
对于 Optional
, pure
的实现就是构造函数 Value
。而 <*>
就是对函数与值都进行模式匹配,在有值的情况下将值应用给函数。
对于列表来说,情况可能稍微复杂一点。因为 <*>
的参数可能是多个函数和多个值。因此我们可以遍历所有可能的 函数-值
组合,因此我们只需要两次 lmap
。比如对于给定的函数列表 fx
与值列表 xs
, lmap (`lmap` xs) fx
先遍历 fx
再遍历 xs
。但是它还有一个问题
fx = (+1) `Cons` (*2) `Cons` Nil xs = 1 `Cons` 2 `Cons` Nil lmap (`lmap` xs) fx -- [ [ 2, 3 ], [ 2, 4 ] ]
两次 lmap
的结果,使结果变成了 List (List Integer)
,而 <*>
应该返回的是 List Integer
。因此我们还需要再设计一个 join
函数,来将结果转化为 List Integer
。对于 List
,这个函数通常叫 concat
lappend :: List a -> List a -> List a lappend Nil ys = ys lappend (Cons x xs) ys = Cons x (lappend xs ys) lconcat :: List (List a) -> List a lconcat Nil = Nil lconcat (Cons x xs) = lappend x (lconcat xs)
因此,我们就能完整实现 List
的 Applicative
示例了
instance Applicative List where pure x = x `Cons` Nil fx <*> xs = lconcat $ lmap (`lmap` xs) fx
实现Monad
在Haskell中, Monad
是采用上一篇中提到的 flatMap
形式定义的
class Applicative m => Monad m where (>>=) :: forall a b. m a -> (a -> m b) -> m b
>>=
就是 flatMap
函数。它的行为就是取第一个参数 m a
的值,将其应用在第二个参数的函数(这个函数也叫monadic map)。由于这个函数并不是在容器中的,因此 >>=
的实现比起Applicative要更容易些。对于 Optional
,我们只需要对 m a
进行模式匹配
instance Monad Optional where (Value val) >>= f = f val Empty >>= _ = Empty
对于列表,我们也只需要遍历一次了。
instance Monad List where xs >>= f = lconcat $ lmap f xs
至此,我们就在Haskell中完成了Monad的实现。
Do-notation
Do表记(do-notation)是Haskell给Monad操作提供的语法糖。在不使用Do表记情况下,使用Monad的代码是相当混乱的。比如
list1 :: List Integer list1 = 1 `Cons` 2 `Cons` Nil list2 :: List Integer list2 = 3 `Cons` 4 `Cons` Nil result = list1 >>= \x -> list2 >>= \y -> let z = x + y in if odd z then return (x, y) else Nil -- reuslt = [ (1,4), (2,3) ]
这段代码计算两个列表所有数字和为奇数的取法。但是这段代码的可读性实在有限, >>=
之后使用λ函数的语法是相当反直觉的,和一般编程语言中“赋值”的书写方向完全相反。而在Do表记下,这段代码可以修改为
result = do x <- list1 y <- list2 let z = x + y if odd z then return (x, y) else Nil
写出来的代码就十分清爽了,颇有命令式语言的味道。在IO操作中,这个优势还可以变得更加的明显。Haskell采用Monad实现IO相关的API,这个Monad就称为 IO Monad
。通过Do表记可以写出很多符合直觉的代码,比如
main :: IO () main = do putStrLn "Hello" putStr "Plz enter your name: " hFlush stdout input <- getLine putStrLn $ "Hi " ++ input -- Hello -- Plz enter your name: KAAAsS -- Hi KAAAsS
不过这段代码和之前有一个不同。Haskell中的IO函数都会返回一个IO Monad,而上面的代码中,我们并没有对每一条都使用之前的结果。对于部分IO Monad(如 putStrLn
返回的),我们直接就抛弃了这些返回值。因此如果把语法糖展开,这段代码将会变成这样
main = putStrLn "Hello" >>= \_ -> putStr "Plz enter your name: " >>= \_ -> hFlush stdout >>= \_ -> getLine >>= \input -> putStrLn $ "Hi " ++ input
为了防止这种无意义的λ参数频繁出现,因此Haskell中还提供了丢弃上个结果的 >>
函数,它的实现是这样的
(>>) :: forall a b. m a -> m b -> m b m >> k = m >>= \_ -> k
因此这段代码可以改写为
main = putStrLn "Hello" >> putStr "Plz enter your name: " >> hFlush stdout >> getLine >>= \ input -> putStrLn $ "Hi " ++ input
再论Applicative
不知道你在实现和使用Monad的时候有没有发现,Monad好像几乎不怎么依赖于Applicative的实现。事实上,在Monad定义中直接使用Applicative的地方只有 return
函数
class Applicative m => Monad m where (>>=) :: forall a b. m a -> (a -> m b) -> m b m >> k = m >>= \_ -> k return :: a -> m a return = pure
那Haskell为什么要规定必须先实现Applicative才能实现Monad呢?这其实是一个历史问题,而且只有GHC版本在7.10之后才有这个要求。虽然Monad本身在1996年就由Philip Wadler提议加入Haskell,但是Applicative其实是2007年于 Functional Pearl: applicative programming with effects 提出的,因此Applicative引入Haskell的时间其实要远远晚于Monad。而由于要保持兼容性,所以在很长一段时间内Applicative与Monad的定义都是不相干的。这个不仅仅表现在它们Typeclass的定义上,在很多标准库函数上也出现了“分歧”。包括:
-
return
(Monad)和pure
(Applicative)函数实际上是一致的 -
>>
(Monad)和*>
(Applicative)实际上是一致的 -
liftM
、liftA
和fmap
是一致的 -
liftM*
(如liftM2
)和liftA*
(如liftA2
)是一致的 -
<*>
和ap
是一致的 -
Traversable
实际上只要求Applicative,但是实现上却要求Monad
这么多明明相同的东西却有那么多不同的表示方法(甚至写的烂的话,它们的行为还会不同),可想而知这会给编码造成多大的混乱。因此在2014年,Haskell社区提出了AMP将这些问题都做了统一,之后由GHC 7.10对相关提议做出了实现。
不过,这也只解释了为什么如今Haskell的Applicative和Monad是这种状态。那么,是什么原因使Haskell冒着把标准库搞乱的风险也要引入Applicative呢?
引入Applicative的意义
上下文无关
引入Applicative的意义有很多,其中一点就是,Applicative和Monad的侧重点不同。Applicative和Monad都能实现运算的组合与排序,因此它们都能对运算进行建模,但是Applicative在运算的过程中并没有上下文。
上下文指的就是之前产生的运算结果,也就是Do表记中的类似“变量”的东西。而没了上下文,这就意味着Applicative失去了根据之前运算结果进行下一步运算的能力。在调用形式上看, >>=
的左侧是之前的运算结果,而右侧通过λ参数将这个结果引入了进来,以供之后使用。但是 <*>
的左侧与右侧并没有联系,因此之后的运算是无法依赖于之前的运算的。
比如对于列表推导式 [ x + y | x <- [1..3], y <- [1..x] ]
,它计算 y
的时候需要就需要先对 x
进行计算。因此使用Applicative是没有办法表达的
(+) <$> [1..3] <*> [1..x] -- error: Variable not in scope: x
而Monad是可以完成这个计算的,在形式上,这个 x
是由λ函数的参数引入的
[1..3] >>= \x -> [1..x] >> \y -> return (x + y)
不过这个例子并不能完全说明“不能用之前的计算结果”,或者“之前的计算结果无法影响之后的计算”。为了更好的理解Applicative运算是固定不变的,我们再举一个实现 if
的例子
ifA :: Applicative f => f Bool -> f a -> f a -> f a ifA t c a = g <$> t <*> c <*> a where g b x y = if b then x else y ifA (Value True) (Value 666) (Value 233) -- Value 666 ifA (Value False) (Value 666) (Value 233) -- Value 233
这么看起来似乎第一个 Optional Bool
真的能影响之后的计算流程?但是实际上,无论 Optional Bool
是真是假,这个 ifA
的计算流程都是不会改变的,看这个例子
ifA (Value True) (Value 666) Empty -- Empty ifA (Value False) (Value 666) Empty -- Empty
回想 Optional
关于 <*>
的实现,就不难理解结果为什么是 Empty
了。而使用Monad,则可以解决这个问题
ifM :: Monad m => m Bool -> m a -> m a -> m a ifM mbool th el = do bool <- mbool if bool then th else el ifM (Value True) (Value 666) Empty -- Value 666 ifM (Value False) (Value 666) Empty -- Empty
从这个例子不难看出,Applicative的计算流程是固定的,它没有执行计算的“上下文”。而Monad的计算流程是可变的,这也意味着它的计算有“上下文”。一般的计算场景中都是有上下文的,比如IO运算。但是这种没有依赖的计算场景其实也是存在的,比如并发、Parser。试想这个场景
(\a b -> merge a b) <$> doTaskA <*> doTaskB
由于 doTaskA
与 doTaskB
不存在依赖关系,因此可以并行的对其进行计算(或者说,可以用来表示并行的计算任务)。
特例
此外,Applicative是一个比Monad更初级的结构。因此毫无疑问,存在是Applicative但不是Monad的情况。而对Applicative的刻画增强了Haskell的表达能力,比如 Traversable
其实只需要Applicative就可以实现了。这里举一个是Applicative但不是Monad的例子: ZipList
。我们之前实现的 List
在处理多参数时会遍历所有可能组合(笛卡尔积),而 ZipList
更贴近使用习惯,它会按照同一个位置的元素来遍历多个列表。 ZipList
的定义本质上就是 List
newtype ZipList a = ZipList { getZipList :: List a } deriving Show instance Functor ZipList where fmap f (ZipList xs) = ZipList $ lmap f xs
之后我们来实现Applicative。这里用到了一个技巧,Haskell的Applicative实际上是很灵活的,它允许我们在声明时选择 <*>
或 liftA2
进行声明。 liftA2
的作用就是上一篇中提到的 liftM2
。
class Functor f => Applicative f where {-# MINIMAL pure, ((<*>) | liftA2) #-} pure :: a -> f a (<*>) :: f (a -> b) -> f a -> f b (<*>) = liftA2 id liftA2 :: (a -> b -> c) -> f a -> f b -> f c liftA2 f x = (<*>) (fmap f x) (*>) :: f a -> f b -> f b a1 *> a2 = (id <$ a1) <*> a2 (<*) :: f a -> f b -> f a (<*) = liftA2 const
MINIMAL
注释就是用来说明这一点。由于“按位置遍历”的操作用 liftA2
很容易就能表达,因此我们可以直接使用 liftA2
来定义。
repeatList :: a -> List a repeatList x = xs where xs = x `Cons` xs zipListWith :: (a -> b -> c) -> List a -> List b -> List c zipListWith f Nil _ = Nil zipListWith f _ Nil = Nil zipListWith f (x `Cons` xs) (y `Cons` ys) = f x y `Cons` zipListWith f xs ys instance Applicative ZipList where pure x = ZipList (repeatList x) liftA2 f (ZipList xs) (ZipList ys) = ZipList (zipListWith f xs ys)
简单测试一下
import Data.Semigroup incList :: Integer -> List Integer incList i = i `Cons` incList (i + 1) a = ZipList ('a' `Cons` 'b' `Cons` 'c' `Cons` 'd' `Cons` Nil) b = ZipList ('5' `Cons` '6' `Cons` '7' `Cons` Nil) c = ZipList (incList 1) (\a b -> (a, b)) <$> a <*> b -- ZipList {getZipList = [ ('a','5'), ('b','6'), ('c','7') ]} (\a b c -> stimes c [a, b]) <$> a <*> b <*> c -- ZipList {getZipList = [ "a5", "b6b6", "c7c7c7" ]}
可以看到, ZipList
的行为和 List
是有很大区别的。而且 ZipList
实际上是没有合法的Monad实现的。这里的合法不是说你实现Monad会报错,而是说 你写的任意Monad都不符合Monad必须符合的定律
。至于这个定律是什么,在讲原理的文章中我会详细说明。
Monad的三种定义
之前提到了AMP更改了Monad的定义,而在AMP真正实现之前,Monad的定义是这样的
class Monad m where return :: a -> m a (>>=) :: m a -> (a -> m b) -> m b (>>) :: m a -> m b -> m b m >> n = m >>= \_ -> n fail :: String -> m a
而这个定义已经是Monad的完备定义了。引入AMP后,由于 return
已经被Applicative的 pure
实现,因此只需要实现 >>=
。
class Applicative m => Monad m where (>>=) :: forall a b. m a -> (a -> m b) -> m b m >> k = m >>= \_ -> k return :: a -> m a return = pure
不过上一篇文章中提到,通过 join
也可以定义Monad。而且在范畴论中,Monad也是这么定义的。所以实际上这个定义也可以定义一个Monad
class Applicative m => Monad m where join :: m (m a) -> m a
后记
这篇文章其实已经远远超出我预计的篇幅了。就这些内容能写这么多,我是没有想到的。原本这篇文章是想简单讲讲Monad的实现,之后再写点Haskell中常见的Monad的。但是由于上一篇文章的Applicative拖到了这篇,导致可以讲的内容大大增加。所以最终这篇文章就变成几乎纯实现Monad的介绍了,而关于Monad的应用、副作用等等的话题就要另开一篇了。不过这样的好处是,我在下一篇可以讲更多有意思的Monad了,说不定还能讲讲Arrow Type和Monad,为更后面的范畴论做些预备。
Reference
- Prelude – Hackage( http://hackage.haskell.org/package/base-4.14.0.0/docs/Prelude.html )
- Brent Yorgey, The Typeclassopedia
- Applicative Programming with Effects( http://www.staff.city.ac.uk/~ross/papers/Applicative.html )
- Functor-Applicative-Monad Proposal( https://wiki.haskell.org/Functor-Applicative-Monad_Proposal )
- Difference between Monad and Applicative in Haskell( https://stackoverflow.com/questions/23342184/difference-between-monad-and-applicative-in-haskell )
以上所述就是小编给大家介绍的《当我们谈论Monad的时候(二)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 当我们在谈论synchronized的时候,我们在谈论什么?
- 当你谈论大数据的时候你还在说Hadoop?
- 当我们在谈论高并发的时候究竟在谈什么?
- 当我们谈论 DevOps 时,我们在谈论什么?
- 当我们在谈论单元测试时我们在谈论什么
- 当我们在谈论单测时我们在谈论什么
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Zero to One
Peter Thiel、Blake Masters / Crown Business / 2014-9-16 / USD 27.00
“This book delivers completely new and refreshing ideas on how to create value in the world.” - Mark Zuckerberg, CEO of Facebook “Peter Thiel has built multiple breakthrough companies, and ......一起来看看 《Zero to One》 这本书的介绍吧!