Haskell中的函子(functor),单子(monad)
前言
函子在 C++中也有,但是呢个人认为 C++的那个函子不完全能被称作函子,反而容易误导人。而学习函数式编程应该用一门比较纯粹的函数式语言,以免过多的面向对象,面向过程知识来干扰学习,所以这篇文章的内容都是建立在一门纯函数式语言(Haskell)的基础上,如果你是写 Scala 或者 Lisp(Scheme)等其他函数式语言的,那么本文中的解释也会有一定帮助。
理解这两个概念这篇文章很有帮助(这篇文章好像也是一篇挺经典的文章),以下的概念是我基于这篇文章以及个人理解总结出来的。
函子(Functor)
定义
函子的定义就一句话:实现了fmap函数的一个类型。但是这个概念比较晦涩,不容易理解。
fmap
在详细讲函子之前要先讲讲fmap。
一开始我们有一个值 2,假如说我们想要给它加上 3,那么在 Haskell 中有三种写法:
ghci> 2+3 -- 最正常的中缀写法
5
ghci> (+) 2 3 -- 前缀写法,(+)是加法函数
5
ghci> (+3) 2 -- 由于函数的Currying,(+3)是一个函数。
5显然的,直接对值操作我们可以使用函数,但是假如这个值被裹在一个包裹当中(比如 Maybe(Maybe只有可能是Just something或者Nothing,是用来防止因为空元素出错的类型)),那么我们直接调用这个函数就是不行的。
ghci> (+3) Just 2 -- 这是错的,因为(+3)的类型是Integer -> Integer,而Just 2的类型是Maybe Integer,这显然不行。为了对包裹进行操作,我们就引入了fmap函数。fmap直译过来就是函子映射,类型是fmap :: Functor f => (a -> b) -> f a -> f b他只干一件事,给他一个包裹(函子),给他操作包裹里面东西的方法(函数),他返回给你一个操作好里面东西的包裹(函子)。例如下面这段代码:
ghci> fmap (+3) (Just 2)
Just 5此时,fmap函数是调用了Maybe类内部的那个fmap的函数,将(+3)这个函数应用到内部的数据2中,返回一个操作好的函子Just 5。
详细解释
OK,有了前面的铺垫,接下来可以详细解释一下什么叫做函子
函子是一个类的对象(比如前面的Just 2),里面定义了一个函数fmap,告诉外部的fmap遇到需要对内部数据进行函数操作时应该做什么。
比如前面说的Maybe,内部的定义是这样的:
instance Functor Maybe where
fmap func (Just val) = Just (func val)
fmap func Nothing = Nothing简单解释一下这段代码:第一行是声明,声明它是一个函子类。下面的第一行定义了一个函数fmap如果传入一个函数func和一个对象Just val,那么用这个func函数对val进行操作后返回;如果传入了一个Nothing对象,那么返回Nothing。
那么函数是不是函子呢?函数也的确是个函子,比如下面这段代码:
ghci> fmap (+3) (+2) 2
7当中(+2)是个函子,(+3)是个函数,而函数是个函子,所以两个就会“叠合”,形成一个新的函子(同时也是一个函数)(+5),此时调用这个函子就能够进行操作了。
看完上面这些例子,总结一下就是:函子是一个允许通过一个函数,利用某种操作(fmap),对内部数据进行变换的一个对象。
应用函子(Applicative Functor)
如果我想要简单的写fmap应该怎么办呢?此时,Haskell 的Control.Applicative模块定义了一个运算符<$>,这个东西就类似中缀版的fmap,它的定义是:
(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x其实就是把fmap表示成运算符形式。这种形式的存在并不是没有意义的,在有一些同时需要用到下面这个<*>运算符和fmap时,使用这种中缀写法看起来更加优美(虽然不一定更容易理解,但是牢记一点,Haskell 中要善于通过类型去看出功能,ghci>:t xxx是一个非常 meaningful 的工具)
前面的例子当中显然有一个共性:那就是值是被包在包裹中的,但是函数是没有包在包裹中,这种情况可以使用fmap。假如函数也包在包裹中,如Just (+3)呢,那么应该怎么做呢?
此时可以用运算符<*>来解决这个问题。这个运算符的类型是(<*>) :: Applicative f => f (a -> b) -> f a -> f b。它的操作方式就是:将函数取出,将值取出,两者进行运算,运算后在打包回包裹当中。
ghci> Just (+3) <*> Just 2
5当然,这个运算还有些更加有趣的应用。假如两者都是列表,由于列表也是一个包裹,所以它会将两者排列组合能够得到的所有值全部记录在一个新列表中。
ghci> [(*2),(+1)] <*> [1,2,3]
[2,4,6,2,3,4]操作的过程就是:
| 1 | 2 | 3 | |
|---|---|---|---|
| *2 | 2 | 4 | 6 |
| +1 | 2 | 3 | 4 |
然后将计算出来的所有值放在一个新列表中。
单子(Monad)
什么是 Monad
接下来就到了 Haskell 中比较难理解的一个部分:单子,又叫 Monad。
先回顾一下前面的内容:函子提供了给定函数a -> b和包裹f a从而得到操作后的包裹f b的方法;应用单子提供了给定包裹着的函数f (a -> b)和包裹f a从而得到操作后的包裹f b的方法。而我们可以通过 ghci 看到 Monad 中bind的类型推断是(>>=) :: Monad m => m a -> (a -> m b) -> m b,这和前面的长得很像,但是它有什么意义呢?
首先我们来看一下 Monad 是什么。
Monad 有两大核心:unit和bind。
unit比较类似 C++中的构造函数,通过一个内容值,返回一个使用这个类型包装以后的函数,在 Haskell 里面就是return,看一下下面这段代码。
test :: String -> IO Int
test str = return $ read str这段代码干了一件事情,将一个字符串String转换成IO Int。我们会发现read函数其实只能返回Int,而我们使用return将其返回时,它就带上了IO的类型属性。(这里要特别注意,Haskell 中return和 C++中的return不是一个意思,Haskell 是一门函数式语言,如果返回值的话应该直接跟在等号的后面作为表达式答案(就像方程一样),而 C++是一门面向对象的语言,如果一个函数有返回值就应该使用return)
接下来我们看看bind,bind是允许一个函数访问装在 Monad 当中的内容,但是访问之后要将后果都处理干净,也就是必须也返回原来的 Monad 类型,比如这一段代码:
ghci> half x = if even x then Just (x `div` 2) else Nothing --定义一个函数,如果是偶数就返回除以二后的值,如果是奇数就返回Nothing
ghci> Just 3 >>= half
Nothing
ghci> Just 4 >>= half
2>>=运算符干了两件事情:1.将左侧函数定义域的内容分享给右侧函数。2.将左侧函数的返回值传入到右侧函数。如果我们只要分享定义域,不用管函数返回值,那么用>>符号就行了
这个运算符有点像工厂里面的流水线,将产品的内部不断的进行补充,但是却不改变这个产品的外包装。
既然说是流水线了,所以我们可以连续操作,比如我想要读入一个字符串,然后将这个字符串作为文件名打开文件,然后将文件里面的内容输出,写成 Monad 就是这样的:
getLine >>= readFile >>= putStrLn我们仔细观察一下,不难发现 Monad 只提供了unit操作,但是并没有提供一个类似 extract 的操作,正所谓“一入 Monad 深似海,从此裸值是路人”。我们仔细想想,这是有好处的,这相当于强行的将所有操作限制在 Monad 当中使用bind进行,不允许你将值捞出去,从而保证程序的安全性(这可能涉及到范畴论的知识,但是应该是能够说明更加安全合理的)
这可能涉及到范畴论的知识,但是应该是能够说明更加安全合理的
do 表示法与 Monad 的关系
如果我们现在要有一个函数,读入一行字符串,在后面加三个叹号,输出这个叹号字符串,那么应该怎么样呢?
首先我们分析一下这个问题,读入和输出都是 IO 操作,那么应该装在IO Monad里面,而我们要对这个IO Monad不断进行处理操作,就需要使用>>=运算符,写出来是这样的:
main =
getLine
>>= ( \x ->
return "!!!"
>>= ( \y ->
putStrLn $ x ++ y
)
)我们来分析一下这段代码,在 FP 的学习当中,要从括号最内侧一层层向外分析(类型分析+定义域分析+bind 分析,bind 分析要分析这个 bind 传递了什么返回值):首先,y 所在的这个 lambda 的类型是String -> IO (),它的定义域最广,能够访问这里有名字的所有自变量(x&y);然后我们分析 x 所在的 lambda,这个 lambda 的类型是[Char] -> IO (),它通过bind将它返回的值(IO String)传入到 y 所在 lambda,也就是将"!!!"通过>>=运算符传给 y 所在 lambda,作为这个函数的自变量y被使用;然后分析最外层,getLine返回一个IO String,通过bind传给了 x 所在 lambda,作为这个 lambda 的参数x使用。
而这种写法又臭又长,而且有一堆虚头巴脑的 lambda,所以 Haskell 允许我们通过do表示法来简化这个代码的书写,也就是我们可以写成这样:
main = do
x <- getLine
y <- return "!!!"
putStrLn $ x ++ y在这个实例当中,我们将getLine得到的值通过<-绑定给x(这个绑定很多地方没怎么讲,简单理解就是将 Monad 里面的值绑定的一个变量上面去),然后将"!!!"绑定给y,然后将这两个东西加和输出(这里的x,y都是String而不是IO String,和前面 lambda 内部类型是一样的)。
使用了do表示法,减少了大量的 lambda,也使代码更加简洁明了,颇有命令式编程的味道(其实说实话,命令式编程才是接近自然语言的表达方式)。
为什么要使用 Monad
唔,这是个好问题,事实上如果在不是函数式语言当中 Monad 的确没有什么特别大的意义。但是看一篇文章
首先我们要知道什么是副作用。如果我做了一个操作,对于后续的其他操作不会有影响,那么这个操作就是无副作用的。在大部分语言比如 C++当中,赋值操作就是典型的有副作用的操作:当我们给一个变量赋值的时候,之后用到这个变量的所有函数都会输出不同的结果。而在像 Haskell 这样的纯函数式语言当中,副作用是“不允许”存在的(这里用了引号,后面会讲为什么),如果所有操作都是无副作用的,那么理论上在不同语句当中的执行顺序是任意的(注意:此处说的是不同语句,而出现函数嵌套函数叠合(以及有 let bindings 的嵌套)的时候我们也是视作同一语句的)。但是比如说像 I/O 操作,就是典型的有副作用的操作(当你读入一行后,你再读入一行得到的值和前面就不一定相同了),而为了处理副作用,就需要使用Monad。像IO Monad就是一个比较典型的一个用来处理副作用的 Monad,你既然要处理 I/O 这种有副作用的操作是吧,可以,但是你必须得拉到我的环境中来进行处理(防止你将操作转移出去导致错误),接下来我们会不断进行函数嵌套,然后通过bind来分享返回值和作用域(参考前一小节使用纯 Monad 的写法),此时我们就将原来的并行执行变成了嵌套执行,本来没有顺序我们通过嵌套人为加上了顺序,然后副作用的问题就解决了,因为顺序是确定的,副作用也随之解决了。
Q&A
以下都是我在学习过程中遇到的一些问题以及我学习后总结出的回答,可供参考。
Q:fmap和函数叠合有什么差别?在fmap (+3) (+2) 2中,和函数叠合生成的(+3) . (+2) $ 2结果是一样的。
A:在这个地方的确是一样的,如果我们看一下 Haskell 中对于函数的重载我们就可以发现端倪:
instance Functor ((->) r) where
fmap f g = f . g事实上,函数在作为 Functor 看待时,它的fmap函数就是函数叠合。但是,fmap函数的用途更广,他可以操作一切 Functor。
References
[1]:图解 Functor
[2]:详解函数式编程之 Monad