回忆之前学过的Maybe类型:

Maybe a = Just a | Nothing

Just 3 :: Maybe Int
Nothing :: Maybe a

我们说Maybe a代表一个装着a类型的值或什么都没装的盒子。当得到了一个Maybe a类型时,我们不能只处理盒子里有值的情况,还需要处理盒子是Nothing的情况:

addOneMaybe :: Maybe Int -> Maybe Int
addOneMaybe (Just a) = Just (a + 1)
addOneMaybe Nothing = Nothing

addOneMaybe (Just 3)
-- 4
addOneMaybe Nothing
-- Nothing

在上面的例子中,我们希望可以处理一个包裹在Maybe里的Int数字,所以通过模式匹配提取了数字并加以处理,但是由于有可能拿不到数字,所以这个计算本身也有可能失败,于是我们把结果又重新包回了一个Maybe类型的盒子里,并且绑定了一个失败情况下的addOneMaybe,即如果遇到了参数是Nothing的情况,直接返回一个Nothing。

再看看一个列表的例子:

addOneList :: [Int] -> [Int]
addOneList = map (+1)
addOneList [1,2,3]
-- [2,3,4]

这次希望能够处理包裹在[]里的数字,我们的策略是把列表里的数字全部处理一遍,再重新放回列表中,而之前学习的map函数正好帮我们完成了把每个元素都处理一遍的任务。

上面这两个例子的函数存在一个共同点:希望处理包裹在盒子的值,处理完成之后再放回盒子里,而如果盒子携带某些额外信息(例如Nothing表示失败,列表表示多个元素),就保留这些额外的信息不变。通过这个过程抽象出的一个通用类型就是:

someFunction :: f a -> f b

其中,f是一个类型变量,例如刚刚的列表或者Maybe,而实际上我们一般都有一个处理单独元素的函数:

someFunction :: a -> b

例如上例的(+1),问题是我们能不能用一个统一的方法,从a -> b这个函数得到f a -> f b呢:

fmap :: (a -> b) -> f a -> f b

在Haskell中,我们把抽象出来的这个函数记作fmap,而被抽象出来的类型(列表和Maybe等)叫作函子。对于列表来说,很容易看出map就是我们需要的fmap,但是对于Maybe呢?

fmap :: (a -> b) -> [a] -> [b]
fmap = map

fmap :: (a -> b) -> Maybe a -> Maybe b
fmap f (Just a) = Just (f a)
fmap _ Nothing = Nothing 

看来也并不复杂,我们在接收到普通函数f和装在盒子里的Just a之后,用f a得到处理后的值,再用Just重新装到Maybe的盒子里。而如果遇到的是Nothing,则直接返回Nothing即可,因为Nothing可以代表任何类型的失败。

我们发现当盒子的类型不同时,fmap的实现也不同,而类型类正好是为了解决这个问题诞生的!下面定义函子这个类型类:

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

我们还需要提供列表和Maybe的类型类实例声明:

instance Functor [] where
fmap = map

instance Functor Maybe where
fmap f (Just a) = Just (f a)
fmap _ Nothing = Nothing 

然后就可以统一使用fmap来处理列表和Maybe了!由于函子类型类以及很多常见的类型的实例声明在Prelude中都已经提供了,我们可以去GHCi试试:

Prelude> fmap (+1) (Just 10)
Just 11
Prelude> fmap (+1) Nothing
Nothing
Prelude> fmap (+1) [1,2,3]
[2,3,4]
Prelude> fmap (+1) []
[]
Prelude> take 3 $ fmap (+1) (repeat 1)
[2,2,2]

其他是函子的类型(例如双元组(,) a):

instance Functor ((,) a) where
fmap f (x, y) = (x, f y)

fmap (+1) (2,3)
-- (2,4)

(a,b)是类型(,) a b的语法糖,我们把元组的第二个元素,也就是类型是b的元素当作盒子里等待处理的元素,而把(,) a当成一整个盒子,所以实例声明的时候是instance Functor ((,) a),代表(,) a这一整个类型是函子。当我们把函数通过fmap作用到双元组时,我们其实只是把函数作用在第二个元素上面而已。

这里有个很有意思的函子需要费点脑筋,我们说函数类型中a ->这部分也是个函子,其中b是包裹在这个函子里的元素,而a ->这部分是构成函子的盒子。和元组类似,在Haskell中,a -> b是(->) a b的语法糖,所以说(->) a类型是一个函子,仔细想想如果我们想把某个b -> c的函数作用在盒子中b类型的元素上时,应该怎么处理?

instance Functor ((->) a) where
    fmap = ???

根据(b -> c) -> f b -> f c的类型,我们把f换成(->) a得到(b -> c) -> (a -> b) -> (a -> c),这正好是之前说过的组合函数(.)的类型。可以这样来理解这个fmap的过程:fmap拿到了包裹在(->) a函子中的b类型的值和处理b类型元素的b -> c类型的函数后,通过组合函数把这个b -> c函数作用到盒子中b类型的结果上,得到c类型的结果,而得到的a -> c的函数也可以看作是一个装在(->) a类型的盒子里的c类型的值,正好符合fmap的类型要求,于是得到了(->) a的函子的定义:

fmap :: (b -> c) -> ((->) a b) -> ((->) a c)
-- 也就是
fmap :: (b -> c) -> (a -> b) -> (a -> c)
instance Functor ((->) a) where
fmap f fa = f . fa
-- 或者
fmap = (.)
fmap (*2) (+1)
-- \x -> (x + 1) * 2
fmap (*2) (+1) $ 3
-- 8 

这种把函数当作盒子来理解的方式,后面很多时候还会用到。综上所述,函子这个类型类提供了一个很重要的抽象:容器类型。也就是说,函子类型基本上都可以看作某种类型的容器。除了列表和Maybe之外,我们还会遇到Vector、Array、Either等容器类型,我们通过fmap抽象出了一个通用的、可以作用在容器类型上的操作,进而可以通过fmap写出适用于任何容器类型的其他函数而不必关心具体类型是如何处理的,这是使用函子抽象的一个重要原因。

评论

本文目前还没有评论……

我要评论

需要登录后才能发言
登录未成功,请修改提交。

× 451
× 1756
× 2435
× 936
× 1
× 1
× 1192
× 0
× 1
× 0
× 2
× 1
× 3
× 4
× 2751
× 817
× 1104