【Haskell】函子 · 应用函子 · 单子

Haskell有三宝:函子、应用函子和单子。它们在Haskell中的地位非常重要,也是通往Haskell进阶的必经之路。

概念的重要性
在正式开始之前,先来扯一点闲话,当然你也可以跳过这部分。概念是对一类具体事物的抽象化表达,正确的理解概念其实是非常重要的。有时候我们觉得难,往往就是没有正确理解概念,你不理解它,概念就只是一个名词而已。
理解概念是从具象化到抽象化的一个过程。具象化是要建立概念到具体事务的联系,明白这个概念描述的是什么。抽象化是从具体事物的共有特性,对比概念对这些特性的描述。你不需要抽象出概念,因为概念已经被抽象出来了,你只是学习它。有了从具象化到抽象化的过程,我们就很容易想明白为什么要抽象出这个概念,这也是很重要的。


函子

函子,当我在脑海中搜索了10分钟之后,我看到了三个数字:404。在以往的认知中,并没有任何东西能与之对应。面对陌生的东西,我们可以从提问开始,比如我们可以试着提出以下问题。

  1. 函子抽象的是谁?
  2. 函子抽象的是一种什么行为?
  3. 为什么要抽象出函子?
  4. 函子本质上是什么?

对于第一个问题我们,我们可以先来看看有哪些具体的"东西"是函子。举几个例子,Maybe是函子,Either是函子,列表也是函子。而它们都是类型,事实上也是如此,函子抽象的就是类型。不过这里还有一个问题:

  1. 函子是否可以抽象所有的类型?

这个问题的答案是否定的,于是又有了一个新的问题:

  1. 函子抽象的是什么样的类型?

再次回到MaybeEither和列表的例子,它们都可以装数据。这个问题的答案就是函子抽象的是一种可以 装 任意数据 的 类型。我们来看看类型前面的这些限定词,它们同样重要。

  • 可以表示这种类型不一定真的装有数据,但是一定要有装数据的能力。比如[]Nothing都没有数据,但是它们有装数据的能力。
  • 可以理解为一种包裹,这层包裹往往具有含义。比如Just 5就是Maybe包裹了数字5,它的含义是可能有。
  • 任意数据说明该类型包裹的数据并不是一个具体类型。用专业的话讲就是这个类型必须有一个类型参数,也就是它的kind是*->*

虽然推理过程稍显潦草,但是我们的目的并不是严谨的推理,而是理解概念。这种类型也被称为上下文,在有些地方也被比喻成盒子或者容器。盒子的比喻其实很生动形象,但那是在理解了函子的真正含义之后。用盒子来解释函子对初学者并不友好,如果是一个老鸟,看到这样比喻会露出会心一笑,如果是菜鸟,那就是小朋友你是否有很多问号了。
因为Haskell并没有一个盒子的概念,你无法将它与你已知的东西建立联系,当你用自己的经验来具象化盒子这个概念时,你怎么也想不到盒子居然就是类型。当你在两者之间建立起联系时,才会豁然开朗。

现在来看第二个问题,我们还是沿用上下文的说法,将函子所抽象的那一类类型称为上下文,那么函子抽象的行文就是一种将上下文无关的计算应用于上下文的能力。上下文我们已经理解,其实就是一种类型,它里面包裹着另一种类型的值。上下文无关的计算指的是一个函数,这个函数的参数是上下文所包裹的类型,而与上下文本身无关。其实这种能力我们在列表中已经见过了,那就是map

函子抽象的这种行为是一个叫做fmap的函数,也就是这个函数把上下文无关的计算应用到上下文的。我们来看几个具体的例子。

> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
> :t map
map :: (a -> b) -> [a] -> [b]
> fmap (2^) [1..5]
[2,4,8,16,32]
> fmap (2^) []
[]
> fmap (+1) (Just 1)
Just 2
> fmap (+1) Nothing
Nothing

对于第三个问题,其实到这里答案已经很明显了。数据总是被包裹到各种这样的上下文中,而针对数据的计算却与上下文无关。计算本身并不关心数据披着怎样的外衣,就像你并不关心你老婆穿着什么样的衣服。问题就在于一旦数据被包裹到上下文,原本处理这些数据的函数就不认得它了,它们无法处理这些上下文。这就好比你老婆换了件衣服你就不认识她了,那肯定是不行的。于是我们需要一种能力,它能帮我们把值从上下文取出,然后计算,最后放回上下文。否则我们将会为了适应上下文而不停的定义各种参数不同但功能重复的函数,这简直是枯燥至极的工作。

函子的本质是类型类,它的定义如下。

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

函子就是Functor的翻译。类型类类似于Go语言的接口,描述的是类型的行为。既然是类型类,那么我们可以尝试着让自定义类型成为Functor的实例,并实现fmap函数。

data Color a = Color a a a deriving Show

instance Functor Color where
    fmap f (Color r g b) = Color (f r) (f g) (f b)

data RGB a = R | G | B deriving Show

instance Functor RGB where
    fmap _ R = G
    fmap _ G = B
    fmap _ B = R

我们定义了ColorRGB类型,它们都是Functor的实例,也就是说它们现在都死函子。注意RGB类型的定义,虽然类型参数实际上并没有用到,但是我们说过函子实例必须有装任意数据的能力,至于装不装其实无所谓。我们可以在ghci中对它们使用fmap函数。

> fmap (+1) (Color 10 100 50)
Color 11 101 51
> fmap even R
G
> fmap odd G
B
> fmap (*2) B
R

关于函子,还有一个有趣的实例:函数。

函数的类型是->,它kind是* -> * -> *。显然它还不能成为函子,因为函子类型的kind是* -> *,但是如果我们给函数一个参数,变成(->) a类型,它的kind是* -> *,此时就具备了称为函子的条件。

> :k (->)
(->) :: * -> * -> *
> :k ((->) Int)
((->) Int) :: * -> *

函数其实也是一种上下文,表达的含义是计算。甚至你可以将函数想象成一个装有值盒子,调用函数就是从这个盒子里取出值。一个a -> b的函数,如果我们给它一个a类型的参数然后停止执行,此时整个函数就是一个容器,在随后的日子里我们可以重整个容器中取出一个b类型的值。

fmap的类型是(a -> b) -> f a -> f b,我们可以试着推导一下将它用于函数的结果,只需要将f替换成(->) c即可。

  (a -> b) -> f a -> f b 
= (a -> b) -> (->) c a -> (->) c b 
= (a -> b) -> (c -> a) -> (c -> b)

可以看到,将一个a -> b的函数fmap到一个c -> a的函数,最终得到了一个c - > b的函数。不难看出,这就是函数组合.,事实上也的确如此。

instance Functor ((->) r) where
	fmap = (.)

我们可以简单思考下,当通过fmap组合两个函数时,函数的执行顺序是什么,也就是说参数会先传递给哪个函数?其实这个问题也很容易,参数会先传递给最靠近它的函数,也就是fmap的第二个参数。因为fmap的第二个参数是一个函子类型,而一个函数只有接收到一个参数后才能成为函子。下面来看几个例子。

> fmap show (+1) 9
"10"
> show `fmap` (+1) $ 9
"10"
> show . (+1) $ 9
"10"

让我们再来看一个概念:升格

升格说的是提升函数的逼格,当我们通过部分应用将一个a -> b的函数应用于fmap时,将会得到一个f a -> f b的函数,这一转变过程就是升格。这样我们就不必再去定义适配每种上下文的函数,通过升格就能马上得到一个新的函数,逼格瞬间就上去了。

函子律

函子律是函子抽象应该遵守的规则。
函子律是对fmap函数的实现的约定。不过Haskell并不能判断fmap的实现是否满足函子律,这一点需要实现者自己保证。函子律有两条规则,描述如下。

  1. fmap id ≡ id
  2. fmap (f . g) ≡ fmap f . fmap g

id是一个将参数原样返回的函数。

> :t id
id :: a -> a
> id "haskell"
"haskell"

函子律第一条说的是fmap只能将函数应用于上下文中的数据,不能夹带私货,做其他计算。比如在前面例子中我们实现了RGB显然就不符合函子律,无论是第一条还是第二条,它都不符合。


应用函子

有了函子的基础,应用函子其实是非常简单的。函子解决的是单参数函数的升格问题,所谓升格问题,也可以理解为如何把一个m a的参数应用到a -> b的函数。而应用函子则是为了解决多参数函数的升格问题。要理解应用函子抽象的行为以及为什么要抽象出应用函子,还得从函子说起。

当我们用fmap对一个a -> b的函数进行升格时,会得到一个m a -> m b的函数。而当我们用fmap去升格一个a -> b -> c的函数时,会得到一个m a -> m (b -> c)的函数,这就是部分应用的魔力。当升格后的函数接收到m a的参数后得到了一个包裹在上下文中的函数,如果想继续应用参数,就不得不先通过模式匹配把函数从上下文中解救出来。

> let a = fmap (+) $ Just 1
> :t a
a :: Num a => Maybe (a -> a)

一边是包裹在上下文中的函数,一边是包裹在上下文中的值,如果有一个函数能从上下文中取出函数和值,然后把计算结果包裹回上下文,问题就迎刃而解了,这就是应用函子所抽象的行为。更重要的是这一过程可以源源不断的进行下去,不管函数有多少参数,只要不断调用这个函数就行了。

作为函子的升级版,应用函子抽象的对象也是一种带有"容器"性质的类型,这一点是必然的,因为一个类型成为应用函子的首要前提就是它必须是函子。其次,应用函子本质上也是类型类,它定义了两个函数。

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

pure提供了将一个值(函数也是值)装进最小上下文的能力,而中缀函数<*>就是应用函子版的fmap。如果你的参数都是包裹在上下文中的,那么就可以通过<*>依次把参数应用到函数,而将普通函数包裹进上下文既可以通过fmap升格,也可以通过pure函数升格。

> fmap (+) Just 1 <*> Just 2
Just 3
> pure (+) <*> Just 1 <*> Just 2
Just 3
> pure (+) <*> Just 1 <*> Nothing
Nothing
> pure (+) <*> [1,2] <*> [3,4]
[4,5,5,6]
> pure (+) <*> [1,2] <*> []
[]

虽然我们可以通过fmap升格将函数包裹进上下文,但是在书写风格上于<*>显得不搭,于是Haskell定义了一个中缀版的fmap函数<$>

> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
> :t (<$>)
(<$>) :: Functor f => (a -> b) -> f a -> f b
> (+1) <$> Just 1
Just 2
> (+) <$> Just 1 <*> Just 2
Just 3

应用函子律

应用函子的实现也有4条需要遵守的规则:

  1. 单位律
    pure id <*> v ≡ v
  2. 组合律
    pure (.) <*> u <*> v <*> w ≡ u <*> (v <*> w)
  3. 同态律
    pure f <*> pure x ≡ pure (f x)
  4. 互换律
    u <*> pure y ≡ pure ($ y) <*> u

同样,这些定律只能依靠应用函子的实现者来保证。与函子类似,对于应用函子的实现者,出来应用函数,不要搞其他的骚操作。


单子

单子也是类型类,首先来看一下它的定义。

class Monad m where
	return :: a -> m a
	
	(>>=) :: m a -> (a -> m b) -> m b
	
	(>>) :: m a -> m b -> m b
	x >> y = x >>= \_ -> y
	
	fail :: String -> m a
	fail msg = error msg

单子一共定义了4个函数,其中两个有默认实现。

return就是应用函子中的pure,用来添加最小上下文。不要将Haskell的return和命令式语言的return混淆,在Haskell中并没有提前结束函数这样的语义。

>>=是一个中缀函数,它就是单子版的fmap

>>用来忽略左边单子的结果,咋看起来没啥用,但其实它的用法是很妙的,后面我们会在例子中看到它的神奇妙用。

fail用来处理失败的计算,但它一般都会被重写,所以在常用的单子如Maybe和列表中我们看不到它的身影,不过我们可以通过自已实现单子来实验。

粗把fmap>>=来观瞧,乍看起来还挺像的。按道理来说,我们有函子来决绝单参数函数应用问题,有应用函子来解决多参数应用问题,为什么还要有单子呢?且再把fmap>>=定睛细瞧,原来它们还是有区别的,也正是这些区别让单子像开了挂一样。

1)逆天改命

首先是>>=的参数顺序反过来了,fmap的第一个参数是函数,而>>=的第二个参数才是函数。

> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
> :t (>>=)
(>>=) :: Monad m => m a -> (a -> m b) -> m b

举一个没有营养的栗子,我们书写将Just 1先加1再乘2,比较下单子版和函子版。单子版是从左往右书写,更符合现代人的阅读习惯。而函子版是从右往左书写,如果是在古代,一定会很受欢迎。

#单子版
Just 1 >>= \x -> Just (x + 1) >>= \y -> Just (y * 2)
#函子版
fmap (*2) $ fmap (+1) $ Just 1

2)算无遗策

第二点区别是函数类型,fmap接收的函数是a -> b,而>>=接收的函数是a -> m b,也因如此,在单子中我们经常看到\a -> xxx这样的λ表达式。看起来是更麻烦了,为何要如此呢?

在函子中,在计算的一开始,其结果的上下文就已经由参数决定了。在计算的过程中,我们没有机会对上下文动手脚。反观单子,每一步计算都带有上下文,要动手脚简直易如反掌。

再比如我们要计算倒数,但是遇到0的时候返回Nothing

> fmap (1/) $ Just 0
Just Infinity
> fmap (\a -> if a == 0 then Nothing else Just (1/a)) $ Just 0
Just Nothing
> Just 0 >>= \a -> if a == 0 then Nothing else Just (1/a)
Nothing

对于函子参数的上下文决定了结果的上下文,即便是在计算中也带了上下文,其结果也是上下文套着上下文。单子有一种能力可以将嵌套的多层上下文坍缩到一层,由此可见单子要解决的不再是函数升格问题,而是带有上下文的计算的组合问题。

3)博古通今

单子还有一种神奇魔力,在每一步计算中,都能看到之前的计算结果。比如我们看下面这个例子,计算一个数的一次、二次和三次方和。

demo x = x >>= \a -> return(a*a) >>= \b -> return (a*b) >>= \c -> return (a+b+c)

虽然正经人没这么干的,作为例子将就着看吧。在每步计算的λ表达式中,我们都能看见之前的计算结果,这就很像命令式语言了,比如下面的Go代码。

func demo(x int) {
	a := x
	b := a * a
	c := a * b
	return a + b + c
}

do语法

由于单子的函数是a -> m b,因此我们常常需要写\a -> ...这样的λ表达式,于是Haskell提供了do语法糖来组合单子,在do语法中,每一行都是一个单子,通过<-可以从单子中提取值。单子之间的连接有两种方式:>>=>>

我们把上面算一次、二次和三次方和的例子用do语法重写如下。

demo' x = do
    a <- x
    b <- return $ a*a
    c <- return $ b*a
    return $ a+b+c

和λ表达式版本比起来,前面都好理解,让我们来看最后一行。在次强调,return在Haskell中仅仅是添加单子最小上下文,没有任何结束函数的意思。最后一行的格式和前面都不一样,我们将它展开成完整的λ表达式形式你就明白了。

demo'' x = x >>= \a -> return a >>= \b -> return (a*a) >>= \c -> return (a*b) >> return (a+b+c)

do语法中,有两种语法,a <- m bm b,前一种通过>>=连接,后一种通过>>连接。在do模块的最后必须是一个单子,也是整个do模块的返回值。do语法实际上就是在组合单子,组合的方式是>>=>>,至于计算,就隐藏在每一行的单子中。那么猜猜下面这个例子返回什么?

demo''' = do
	a <- Just 1
	Nothing
	b <- Just 2
	return $ a + b

在涉及IO的操作时,我们也会用到do语法,那是因为IO也是单子。

main = do
    a <- getLine
    b <- getLine
    putStrLn $ a ++ b

还记得在列表篇中我们介绍过一种神奇的集合语法,实际上那也是单子的语法糖。下面两种写法是等价的。

ll = [x+y | x <- [1..10], y <- [x..10]]

ll' = do
    x <- [1..10]
    y <- [x..10]
    return $ x+y

失败

在介绍Monad类型类的定义时,我们提到过fail这个函数,表示失败的计算。这里把它单独拎出来说是因为这里还涉及另一个类型类MonadFail,感兴趣的可以官网看看。

因为Maybe重写了fail函数,我们自己定义一个Have类型来实验fail

import Control.Monad.Fail

data Have a = Empty | Have a deriving Show

instance Functor Have where
    fmap f (Have a) = Have $ f a
    fmap f Empty = Empty

instance Applicative Have where
    pure a = Have a
    (<*>) (Have f) (Have a) = Have $ f a
    (<*>) Empty _ = Empty
    (<*>) _ Empty = Empty

instance Monad Have where
    return a = Have a
    (>>=) (Have a) f = f a
    (>>=) Empty _ = Empty

instance MonadFail Have where
    fail s = error s

mayFail x = do
    (a:b) <- x
    return a

是单子必须首先是应用函子,是应用函子必须首先是函子,所以这一套都不能少。在MonadFail中实现的fail函数其实就是默认实现,它会让程序崩溃。也可以不自己实现fail函数,但是那样编译器就会警告你。

要看到失败,我们只需要让模式匹配失败即可。

> mayFail $ Have [1..5]
Have 1
> mayFail $ Have []
*** Exception: Pattern match failure in do expression at functor_monad.hs:118:5-9
CallStack (from HasCallStack):
  error, called at functor_monad.hs:115:14 in main:Main

如果不想让程序崩溃,我们可以将fail函数修改如下。

instance MonadFail Have where
    fail _ = Empty

当再遇到失败时,就会返回Empty而不是崩溃,就像Maybe一样。

> mayFail $ Have [1..5]
Have 1
> mayFail $ Have []
Empty
> mayFail $ Just []
Nothing

单子律

  1. return x >>= f ≡ f x
  2. m >>= return ≡ m
  3. (m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)

还是那句老话,不要夹带私货。


总结

函数功能类型
函子fmap将函数应用到带有上下文的值(a -> b) -> f a -> f b
应用函子pure添加最小上下文a -> f a
<$>中缀版fmap(a -> b) -> f a -> f b
<*>将上下文中的函数应用到上下文中的值f (a -> b) -> f a -> f b
单子return添加最小上下文a -> m a
>>=连接单子运算m a -> (a -> m b) -> m b
>>连接单子m a -> m b -> m b
fail失败函数String -> m a
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值