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

【Haskell】函子 · 应用函子 · 单子本文深入解析了 Haskell 中的函子 应用函子和单子概念 强调了它们在抽象和计算中的核心作用 以及它们在解决不同类型计算问题中的独特优势和规则

大家好,欢迎来到IT知识分享网。

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的函数,这一转变过程就是升格。这样我们就不必再去定义适配每种上下文的函数,通过升格就能马上得到一个新的函数,逼格瞬间就上去了。

函子律

  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

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/138273.html

(0)
上一篇 2025-06-14 13:15
下一篇 2025-06-14 13:20

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信