类型类是 Haskell 中重要的组成部分,它类似于 Java 中的接口,接下来就让我们一起来看看奇妙的类型类吧。

什么是类型类

Haskell 中可以把具有共同属性(或叫做特征)的类型归为一类,这一类就被称为类型类。 比如常见的 Eq 类型类,代表了一类可以进行 比较相等和不相等 的类型,或是 Ord 类型类,代表了一类可以进行 比较大小 的类型。

函子(Functor)

在这之前先向一些新读者介绍一下 map 函数,map 函数是将一个列表的每一个值都应用上一个函数,然后将应用函数的结果再组成一个列表。很明显,map 函数只会改变列表内元素的类型,而不会改变列表的长度。

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

当然不止列表,树、集合,等等的许多类型都可以有自己的 map 函数,在没有类型类的情况下,就需要对每个类型都定义一个属于它们自己的 map 函数。

mapTree :: (a -> b) -> Tree a -> Tree b
mapTree = ...

mapSet :: (a -> b) -> Set a -> Set b
mapSet = ...

而使用类型类,就是将这些散乱的 map 函数统一(抽象)起来,于是便有了 Functor 类型类。

Functor 类型类只定义了一个函数:fmap

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

抽象出来的 fmap 不仅能用于列表,还能用于几乎所有的容器类型,比如最简单的 Identity 类型。

newtype Identity a = Identity { runIdentity :: a }

不太简单的 Maybe 类型。

data Maybe a = Nothing | Just a

非常不简单的 State 类型(注意:mtl 库中并不是这么定义的,只是为了方便才这么写)

newtype State s a = State { runState :: s -> (a, s) }

Functor 可以看做是你有一个你无法打开的魔法盒子(f a),但是你想对盒子里的数值(a)应用一个函数(a -> b),这个时候就可以求助 fmap,让它帮你把函数应用到数值上,然后把结果再放进盒子里,还给你。

> (+1) `fmap` (Just 1)
Just 2
> (+1) `fmap` Nothing
> Nothing

为了方便,Haskell 标准库中定义了一个和 fmap 等价的 (<$>) 运算符。

> (+1) <$> (Just 1)
Just 2

既然能让一个 Just 1 “加上” 1,那能不能让两个 Maybe Int 值相加呢?

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

会发现函数跑到魔法盒子里面去了,但我们似乎无法通过目前的任何方法把它取出来,或者应用到其他的值上。解决这个问题的,就是接下来要介绍的 Applicative。

可应用函子(Applicative)

Applicative 类型类中主要有两个函数,用来把一个普通的值装进魔法盒子里的 pure,还有将装在魔法盒子里的函数应用到另一个装在魔法盒子里的值的 (<*>)

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

首先是较为简单的 pure,可以把一个普通的值装进 Applicative 中:

> pure 1 :: Maybe Int
Just 1

接着是和 fmap 类似的 (<*>),可以把一个装在 Applicative 中的函数(f (a -> b))应用在另一个 装在 Applicative 中的值(f a)上。 比如可以通过这种方式实现两个 Maybe Int 的相加:

> (+) <$> Just 1 <*> Just 2
Just 3
> pure (+) <*> Just 1 <*> Just 2
Just 3

单子(Monad)

假设你打算用 Haskell 写一个抽象语法树:

data Expr = ILit Int
    | Add Expr Expr
    -- 省略无用的语法
    | Div Expr Expr

然后需要对它们进行求值:

eval :: Expr -> Int
eval (ILit i) = i
eval (Add a b) = eval a + eval b
eval (Div a b) = eval a `div` eval b

但这明显有个问题,一旦作为除数的表达式结果为 0,那么这个表达式就会报错。 能不能做到委婉地返回一个错误值,而不是强硬地报错呢? 我们可以使用用来处理错误的类型:Maybe

safeEval :: Expr -> Maybe Int
safeEval (ILit i) = Just i
safeEval (Add a b) = case safeEval a of
                       Nothing -> Nothing
                       Just a' -> case safeEval b of
                         Nothing -> Nothing
                         Just b' -> Just (a' + b')
safeEval (Div a b) = case safeEval a of
                       Nothing -> Nothing
                       Just a' -> case safeEval b of
                         Nothing -> Nothing
                         Just b' -> if b' == 0 then Nothing else Just (a' `div` b')

但会发现,有许多重复代码:

case something of
  Nothing -> Nothing
  Just m -> something'

于是,可以把这些重复代码提取出来,做成一个新的函数:

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

然后,就可以重新编写我们的 safeEval 函数:

-- ...
safeEval (Add a b) = ifJust (safeEval a) $ \a' -> 
  ifJust (safeEval b) $ b' ->
    Just $ a' + b'
safeEval (Div a b) = ifJust (safeEval a) $ \a' -> 
  ifJust (safeEval b) $ b' ->
    if b' == 0
      then Nothing
      else Just $ a' + b'

而这个 ifJust 函数,就类似于单子的 (>>=)

class Applicative m => Monad (m :: * -> *) where
  return :: a -> m a
  (>>=) :: m a -> (a -> m b) -> m b

单子和可应用函子有一些相同之处,比如都有相同功能的 returnpure。 而 (>>=) 就像上面的 ifJust 函数,可以进行连续的运算并自动处理一些重复的动作(比如重复处理错误的检查)。

safeDiv (Div a b) = safeEval a >>= \a' ->
  safeEval b >>= \b' ->
    if b' == 0
      then Nothing
      else Just $ a' `div` b'

文章最开始介绍到的 IdentityState 也都是 Monad。

Identity 是最简单的 Monad,仅包含了一个值,而不做任何运算:

> Identity 1 >>= \i -> Identity (i + 2)
Identity 3
> (+2) 1
3

State 则较为复杂,以后的文章会做讲解。

Haskell 还针对 (>>=) 设计了一个语法糖,能够看出 (>>=) 对纯函数式编程的重要性。

foo :: IO ()
foo = getLine >>= \s -> putStrLn s

-- 等价于

foo' :: IO ()
foo' = do
  s <- getLine
  putStrLn s

Haskell 标准库还提供了两个函数:liftMap

fmap :: Functor f => (a -> b) -> f a -> f b
liftM :: Monad m => (a -> b) -> m a -> m b
liftM f a = do
  a' <- a

  return (f a')

(<*>) :: Applicative f => f (a -> b) -> f a -> f b
ap :: Monad m => m (a -> b) -> m a -> m b
ap f a = do
  f' <- f
  a' <- a

  return (f' a')

很明显,Functor 的 fmap 和 Applicative 的 (<*>) 都可以用这两个函数来实现。

Monad 的一些特点

Monad 的 (>>=) 是用于进行连续的运算,并自动处理一些重复的事情。 比如 Maybe Monad 就是自动处理错误,一旦一个环节发生错误,接下来的运算都会是错误的。 又或者 State Monad,进行连续运算的同时还维护了一个状态,十分适合在纯函数式编程中模拟变量。

不知道读者有没有发现一个现象,无论是 Functor,Applicative 还是 Monad,都没有能够把数值从这个魔法盒子里拿出来的操作,这就意味着一个值一旦进入了魔法盒子,就再也无法通过 Functor,Applicative 和 Monad 提供的操作拿出来了,这也是 IO Monad(用于处理副作用的 Monad)的一个重要性质。

结尾

Monad 作为纯函数式编程中十分重要的一部分,学习 Monad 是必不可少的。 有趣的纯函数和类型世界里总是充满了困难和惊喜,希望这篇文章能成为推动你学习纯函数式编程的动力。