跳转到内容

Haskell/Monad transformers

维基教科书,自由的教学读本

我们已经学习了Monad能够如何简便对于 IO, Maybe, 列表, 和 State 的处理. 每一个Monad都是如此的有用且用途广泛, 我们自然会寻求将几个Monad的特性组合起来的方法. 比如, 也许我们可以定义一个既能处理I/O操作, 又能使用 Maybe 提供的异常处理的函数. 虽然这可以用形如 IO (Maybe a) 的函数实现, 但是在这种方式下我们将被迫在 IO 的 do 代码块中手工模式匹配提取值, 而避免写出这种繁琐而无谓的代码却恰恰是 Maybe Monad存在的原因.

于是我们发明了 monad transformers: 它们能够将几种Monad的特性融合起来, 同时不失去使用Monad的灵活性.

密码验证

[编辑]

我们先来看一看这个几乎每一个IT从业人员都会遇到的实际问题: 让用户设置足够强的密码. 一种方案是: 强迫用户输入大于一定长度, 且满足各种恼人要求的密码 (例如包含大写字母, 数字, 字符, 等等).

这是一个用于从用户处获取密码的Haskell函数:

getPassphrase :: IO (Maybe String)
getPassphrase = do s <- getLine
                   if isValid s then return $ Just s
                                else return Nothing

-- 我们可以要求密码满足任何我们想要的条件
isValid :: String -> Bool
isValid s = length s >= 8
            && any isAlpha s
            && any isNumber s
            && any isPunctuation s

首先, getPassphrase 是一个 IO Monad, 因为它需要从用户处获得输入. 我们还使用了 Maybe, 因为若密码没有通过 isValid 的建议, 我们决定返回 Nothing. 需要注意的是, 我们在这里并没有使用到 Maybe 作为Monad的特性: do 代码块的类型是 IO monad, 我们只是恰巧 return 了一个 Maybe 类型的值罢了.

Monad transformers 不仅仅使 getPassphrase 的实现变简单了, 而且还能够简化几乎所有运用了多个monad的代码. 这是不使用Monad transformers的程序:

askPassphrase :: IO ()
askPassphrase = do putStrLn "输入新密码:"
                   maybe_value <- getPassphrase
                   if isJust maybe_value
                     then do putStrLn "储存中..." -- 假装存在数据库操作
                     else putStrLn "密码无效"

这段代码单独使用了一行来获得 maybe_value, 然后又手工对它进行验证.

如果使用 monad transformers, 我们将可以把获得输入和验证这两个步骤合二为一 — 我们将不再需要模式匹配或者等价的 isJust. 在我们的简单例子中, 或许 monad transformers 只作出了微小的改进, 但是当问题规模进一步扩大时, 它们将发挥巨大的作用.

一个简单的 monad transformer: MaybeT

[编辑]

为了简化 getPassphrase 以及所有使用到它的函数, 我们定义一个赋予 IO monad 一些 Maybe monad 特性的 monad transformer; 我们将其命名为 MaybeT. 一般来说, monad transformers 的名字都会以 "T" 结尾, 而之前的部分 (例如, 在本例中是"Maybe") 表示它所提供的特性.

MaybeT 是一个包装 (wrap) 了 m (Maybe a) 的类型, 其中 m 可以是任何Monad (在本例中即为 IO):

newtype MaybeT m a = MaybeT { runMaybeT :: m (Maybe a) }

这里的 datatype 声明定义了 MaybeT, 一个被 m 参数化的类型构造子 (type constructor), 以及一个值构造函数 (value constructor), 同样被命名为 MaybeT, 以及一个作用为简便的内部值访问的函数 runMaybeT.

Monad transformers 的关键在于 他们本身也是monads; 因此我们需要写出 MaybeT mMonad 类型类实例:

instance Monad m => Monad (MaybeT m) where
    return  = MaybeT . return . Just

首先, 我们先用 Just 将值装入最内层的 Maybe 中, 然后用 return 将前述 Maybe 装入 m monad里, 最后再用 MaybeT 将整个值包装起来.


我们也可以这样实现 (虽然是不是增加了可读性仍然有待商榷) return = MaybeT . return . return.

如同在所有monad中一样, (>>=) 也是 transformer 的核心.

-- 特化为 MaybeT m 的 (>>=) 函数类型签名 
(>>=) :: MaybeT m a -> (a -> MaybeT m b) -> MaybeT m b

x >>= f = MaybeT $ do maybe_value <- runMaybeT x
                      case maybe_value of
                           Nothing    -> return Nothing
                           Just value -> runMaybeT $ f value

我们从 do 代码块的第一行开始解释:

  • 首先, runMaybeTx 中取出 m (Maybe a). 我们由此可知整个 do 代码块的类型为 monad m.
  • 第一行稍后, <- 从上述值中取出了 Maybe a.
  • case 语句对 maybe_value 进行检测:
    • 若其为 Nothing, 我们将 Nothing 返回至 m 当中;
    • 若其为 Just, 我们将函数 f 应用到其中的 value 上. 因为 f 的返回值类型为 MaybeT m b, 我们需要再次使用 runMaybeT 来将其返回值提取回 m monad 中.
  • 最后, do 代码块的类型为 m (Maybe b); 因此我们用 MaybeT 值构造函数将它包装进 MaybeT 中.

这咋看来获取有些复杂; 但是若剔除大量的包装和解包装, 我们的代码和 Maybe monad 的实现其实很像:

-- Maybe monad 的 (>>=)
maybe_value >>= f = case maybe_value of
                        Nothing -> Nothing
                        Just value -> f value

我们在 do 代码块中仍然能够使用 runMaybeT, 为何要在前者上应用 MaybeT 值构造函数呢? 这是因为我们的 do 代码块必须是 m monad, 而不是 MaybeT m monad, 因为后者此时还没有定义 (>>=). (回想一下 do 语法糖是如何工作的)

注解

return 的实现中层层相叠的函数也许可以为我们提供一个(或许)有助于理解的比喻: 将复合的 monad 想象为一个 三明治; 虽然这个比喻似乎在暗示实际上有三个 monad 在起作用, 但是实际上只有两个: 内层的以及"融合了的" monad. 置于基层的 monad (本例中即Maybe) 中并没有用到 (>>=) 或者 return, 它只是作为 transformer 实现的一部分罢了. 如果你觉得这个比喻很有道理, 将 transformer 和 底层 monad 想象成三明治的两片包裹着内层 monad 的面包. [1]

理论上, 这就是我们所需的全部了; 但是, 为 MaybeT 实现几个其他类型类的 instance 又何妨呢?

instance Monad m => MonadPlus (MaybeT m) where
    mzero     = MaybeT $ return Nothing
    mplus x y = MaybeT $ do maybe_value <- runMaybeT x
                            case maybe_value of
                                 Nothing    -> runMaybeT y
                                 Just _     -> return maybe_value

instance MonadTrans MaybeT where
    lift = MaybeT . (liftM Just)

MonadTrans monad 实现了 lift 函数, 由此我们可以在 MaybeT m monad 的 do 代码块中使用m monad 的值. 至于 MonadPlus, 因为 Maybe 有着这个类型类的 instance, MaybeT 也应该有着对应的 instance.

应用在密码验证的例子中

[编辑]

现在, 前例的密码管理可以被改写为:

getValidPassphrase :: MaybeT IO String
getValidPassphrase = do s <- lift getLine
                        guard (isValid s) -- MonadPlus 类型类使我们能够使用 guard.
                        return s

askPassphrase :: MaybeT IO ()
askPassphrase = do lift $ putStrLn "输入新密码:"
                   value <- getValidPassphrase
                   lift $ putStrLn "储存中..."

这段代码简洁多了, 特别是 askPassphrase 函数. 最重要的是, 有着 (>>=) 为我们代劳, 我们不再需要手动检查结果是 NothingJust 中的哪一个了.

我们使用了 lift 函数以在 MaybeT IO monad 中使用 getLineputStrLn; 因为 MaybeT IO 有着 MonadPlus 类型类的 instance, guard 可以为我们检查代码的合法性. 在密码不合法时其将返回 mzero (即 IO Nothing).

碰巧, 有了 MonadPlus, 我们可以优雅地不停要求用户输入一个合法的密码:

askPassword :: MaybeT IO ()
askPassword = do lift $ putStrLn "输入新密码:"
                 value <- msum $ repeat getValidPassphrase
                 lift $ putStrLn "储存中..."

泛滥的 transformer

[编辑]

transformers 包提供了许多包含常见 monad 的 transformer 版本的模块 (例如 Template:Haskell lib 模块提供了 MaybeT). 它们和对应的非 transformer 版本 monad 是兼容的; 换句话说, 除去一些和另一个 monad 交互所用到的包装和解包装 , 它们的实现基本上是一致的. 从今往后, 我们称 transformer monad 所基于的非 transformer 版本的 monad 为 基层 monad (例如 MaybeT 中的 Maybe); 以及称应用在 transformer 上的 monad 为 内层 monad (例如 MaybeT IO 中的 IO).

我们随便举一个例子, ReaderT Env IO String. 这是一个能够从某个外部环境读取 Env 类型的值 (并且和 Reader, 也就是基 monad, 使用方法相同) 并且能够进行一些I/O, 最后返回一个 String 类型的值的 monad. 由于这个 transformer 的 (>>=)return 语义和基 monad 相同, 一个 ReaderT Env IO String 类型的 do 语句块从外部看来和另一个 Reader 类型的 do 语句块并无二致, 除了我们需要用 lift 来使用 IO monad.

类型戏法

[编辑]

我们已经了解到, MaybeT 的类型构造子实际上是一个对内层 monad 中的 Maybe 值的包装. 因此, 用于取出内部值的函数 runMaybeT 返回给我们一个类型为 m (Maybe a) 的值 - 也就是一个由内层 monad 包装着的基 monad 值. 类似的, 对于分别基于列表和 Either 构建的 ListTExceptT transformer:

runListT :: ListT m a -> m [a]

and

runExceptT :: ExceptT e m a -> m (Either e a)

然而并不是所有的 transformer 都和它们的基 monad 有着类似的关系. 不同于上面两例所给出的基 monad, Writer, Reader, State, 以及 Cont monad 既没有多个值构造子, 也没有接收多个参数的值构造函数. 因此, 它们提供了形如 run... 的函数以作为解包装函数, 而它们的 transformer 则提供形如 run...T 的函数. 下表给出了这些 monad 的 run...run...T 函数的类型, 而这些类型事实上就是包装在基 monad 和对应的 transformer 中的那些. [2]

基 Monad Transformer 原始类型
(被基 monad 包装的类型)
复合类型
(被 transformer 包装的类型)
Writer WriterT (a, w) m (a, w)
Reader ReaderT r -> a r -> m a
State StateT s -> (a, s) s -> m (a, s)
Cont ContT (a -> r) -> r (a -> m r) -> m r

我们可以注意到, 基 monad 并没有出现在复合的类型中. 这些 monad 并没有一个像 Maybe 或列表那样的有意义的构造函数, 因此我们选择不在 transformer monad 中保留基 monad. 值得注意的是, 在后三个例子中我们包装的值是函数. 拿 StateT 作例子, 将原有的表示状态转换的 s -> (a, s) 函数变为 s -> m (a, s) 的形式; 然而我们只将函数的返回值 (而不是整个函数) 包装进内层 monad 中. ReaderT 也与此差不多. ContT 却与它们不同: 由于 Cont (表示延续的 monad) 的性质, 被包装的函数和作这个函数参数的函数返回值必须相同, 因此 transformer 将两个值都包装入内层 monad 中. 遗憾的是, 并没有一种能将普通的 monad 转换成 transformer 版本的万灵药; 每一种 transformer 的实现都和基 monad 的行为有关.

Lifting

[编辑]

我们将仔细研究 lift 函数: 它是应用 monad transformers 的关键. 首先, 我们需要对它的名字 "lift" 做一些澄清. 在理解_Monad中, 我们已经学习过一个名字类似的函数 liftM. 我们了解到, 它实际上是 monad 版本的 fmap:

liftM :: Monad m => (a -> b) -> m a -> m b

liftM 将一个类型为 (a -> b) 的函数应用到一个 m monad 内的值上. 我们也可以将它视为只有一个参数:

liftM :: Monad m => (a -> b) -> (m a -> m b)

liftM 将一个普通的函数转换为在 m monad 上运作的函数. 我们用"提升(lifting)"表示将一样东西带至另一样东西中 — 在前例中, 我们将一个函数提升到了 m monad 中.

有了 liftM, 我们不需要用 do 代码块或类似的技巧就能够把寻常的函数应用在 monad 上了:

do notation liftM
do x <- monadicValue
   return (f x)
liftM f monadicValue

类似的, lift 函数在 transformer 上起到了一个相似的作用. 它将内层 monad 中的计算带至复合的 monad (即 transformer monad) 中. 这使得我们能够轻易地在复合 monad 中插入一个内层 monad 的运算.

liftMonadTrans 类型类的唯一一个函数, 参见 Template:Haskell lib. 所有的 transformer 都有 MonadTrans 的 instance, 因此我们能够在任何一个 transformer 上使用 lift.

class MonadTrans t where
    lift :: (Monad m) => m a -> t m a

lift 存在一个 IO 的变种, 名叫 liftIO; 这是 MonadIO 类型类的唯一一个函数, 参见 Template:Haskell lib.

class (Monad m) => MonadIO m where
   liftIO :: IO a -> m a

当多个 transformer 被组合在一起时, liftIO 能够带来一些便利. 在这种情况下, IO 永远是最内层的 monad (因为不存在 IOT transformer), 因此一般来说我们需要多次使用 lift 以将 IO 中的值从底层提升至复合 monad 中. 定义了 liftIO instance 的类型被设计成能够使用 liftIOIO 从任意深的内层一次性提升到 transformer monad 中.

实现 lift

[编辑]

lift 并不是很复杂. 以 MaybeT transformer 为例:

instance MonadTrans MaybeT where
    lift m = MaybeT (liftM Just m)

我们从接收到一个内层 monad 中的值的参数开始. 我们使用 liftM (或者 fmap, 因为所有Monad都首先是Functor) 和 Just 值构造函数来将基 monad 插入内层 monad 中, 以从 m a 转化为 m (Maybe a)). 最后, 我们使用 MaybeT 值构造函数将三明治包裹起来. 值得注意的是, 此例中 liftM 工作于内层 monad 中, 如同我们之前看到的 MaybeT(>>=) 实现中的 do 代码块一样.

练习
  1. 为什么 lift 必须为每一个 monad transformer 单独定义, 但 liftM 却可以只实现一次呢?
  2. Identity 是一个定义于 Data.Functor.Identity 中实际意义不大的 functor:
    newtype Identity a = Identity { runIdentity :: a }
    它有着如下的 Monad instance:
    instance Monad Identity where
        return a = Identity a
        m >>= k  = k (runIdentity m)
    
    实现一个 monad transformer IdentityT, 这个 transformer 和 Identity 类似, 但是将值包裹在 m a 而不是 a 类型的值中. 请你至少写出它的 MonadMonadTrans instances.

实现 transformers

[编辑]

State transformer

[编辑]

作为一个额外的例子, 我们将试着实现 StateT. 请确认自己了解 State 再继续. [3]

正如同 State monad 有着 newtype State s a = State { runState :: (s -> (a,s)) } 的定义一样, StateT transformer 的定义为:

newtype StateT s m a = StateT { runStateT :: (s -> m (a,s)) }

StateT s m 有着如下的 Monad instance, 旁边用基 monad 作为对比:

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

instance Monad (State s) where
  return a        = State $ \s -> (a,s)
  (State x) >>= f = State $ \s ->
    let (v,s') = x s
    in runState (f v) s'
newtype StateT s m a =
  StateT { runStateT :: (s -> m (a,s)) }

instance (Monad m) => Monad (StateT s m) where
  return a         = StateT $ \s -> return (a,s)
  (StateT x) >>= f = StateT $ \s -> do
    (v,s') <- x s          -- 取得新的值和状态
    runStateT (f v) s'     -- 将它们传递给 f

我们的 return 实现使用了内层 monad 的 return 函数. (>>=) 则使用一个 do 代码块来在内层 monad 中进行计算.

注解

现在我们能够解释, 为什么在 State monad 中存在手工定义的 state 却没有与类型一同定义的 State 值构造函数了. 在 transformersmtl 包中, State s 被定义为 StateT s Identity 的类型别名, 其中 Identity 是在本章练习中出现的没有特别效果的 monad. 这个定义和我们迄今为止所见的使用 newtype 的定义是等价的.

为了将 StateT s m monad 同 State monad 一般使用, 我们自然需要核心的 getput 函数. 这里我们将使用 mtl 的代码风格. mtl 不仅仅提供了 monad transformers 的定义, 还提供了定义了常见 monad 的关键操作的类型类. 例如, Template:Haskell lib 包所提供的 MonadState 类型类, 定义了 getput 函数:

instance (Monad m) => MonadState s (StateT s m) where
  get   = StateT $ \s -> return (s,s)
  put s = StateT $ \_ -> return ((),s)
注解

instance (Monad m) => MonadState s (StateT s m) 的意思是: "对于任何类型 s 以及任何是 Monad instance 的类型 m, sStateT s m 共同组成了一个 MonadState 的 instance". sm 分别对应了 state monad 和内层 monad. 类型参数 s 是 instance 声明的一个独立部分, 因此函数能够访问到 s: 举个例子, put 的类型为 s -> StateT s m ().

存在为其他 transformer 包装着的 state monad 所定义的 MonadState instance, 例如 MonadState s m => MonadState s (MaybeT m). 这些 instance 使得我们不再需要写出 lift 来使用 get and put, 因为复合 monad 的 MonadState instance 为我们代劳了.

我们还可以为复合 monad 实现内层 monad 所拥有的一些类型类 instance. 例如, 所有包装了 StateT (其拥有 MonadPlus instance) 的复合 monad 也同样能够拥有 MonadPlus 的 instance:

instance (MonadPlus m) => MonadPlus (StateT s m) where
  mzero = StateT $ \_ -> mzero
  (StateT x1) `mplus` (StateT x2) = StateT $ \s -> (x1 s) `mplus` (x2 s)

mzeromplus 的实现符合我们的直觉; 它们将实际的操作下推给内层 monad 来完成.

最后, monad transformer 必须有 MonadTrans 的 instance, 不然我们无法使用 lift:

instance MonadTrans (StateT s) where
  lift c = StateT $ \s -> c >>= (\x -> return (x,s))

lift 函数返回一个包装在 StateT 中的函数, 其接受一个表示状态的值 s 作为参数, 返回一个函数, 这个函数接受一个内层 monad 中的值, 并将它通过 (>>=) 传递给一个将其和状态打包成一个在内层 monad 中的二元组的函数. 举个例子, 当我们在 StateT transformer 中使用列表时, 一个返回列表的函数 (即一个在列表 monad 中的计算) 在被提升为 StateT s []后, 将变成一个返回 StateT (s -> [(a,s)]) 的函数. 换句话说, 这个函数用初始状态产生了多个 (值, 新状态) 的二元组. 我们将 StateT 中的计算 "fork" 了, 为被提升的函数返回的列表中的每一个值创建了一个计算分支. 当然了, 将 StateT 和不同的 monad 组合将产生不同效果的 lift 函数.

练习
  1. 使用 getput 来实现 state :: MonadState s m => (s -> (a, s)) -> m a.
  2. MaybeT (State s)StateT s Maybe 等价吗? (提示: 比较两个 run...T 函数的返回值.

致谢

[编辑]

本章摘取了 All About Monads, 已取得作者 Jeff Newbern 的授权.

Template:Haskell/NotesSection



Monad transformers
习题解答
Monads

理解 Monad  >> 高级 Monad  >> Monad 进阶  >> MonadPlus  >> Monadic parser combinators  >> Monad transformers  >> Monad 实务


Haskell

Haskell基础 >> 初级Haskell >> Haskell进阶 >> Monads
高级Haskell >> 类型的乐趣 >> 理论提升 >> Haskell性能


库参考 >> 普通实务 >> 特殊任务

  1. 译者并没有看懂这个比喻, 请批判性地阅读. 看得懂的人希望能够帮忙确认或修改.
  2. 严格意义上, 这个解释只在大于 2.0.0.0 版本的 mtl 包中成立.
  3. 译注: 此处作者引用了 理解_Monad 中的内容, 然而译时此章节并没有翻译完成.