跳转到内容

Haskell/Monad 变换器

来自 Wikibooks,开放世界中的开放书籍

我们已经了解了 Monad 如何帮助处理 IO 操作、Maybe、列表和状态。由于 Monad 提供了一种使用这些有用的通用工具的通用方法,因此我们可能想要做的一件自然的事情就是同时使用多个 Monad 的功能。例如,一个函数可以同时使用 I/O 和 Maybe 异常处理。虽然像 IO (Maybe a) 这样的类型可以正常工作,但它会迫使我们在 IO do 块中进行模式匹配以提取值,而这正是 Maybe Monad 旨在避免的。

Monad 变换器登场:特殊类型,允许我们将两个 Monad 融合成一个共享两者行为的 Monad。

密码短语验证

[编辑 | 编辑源代码]

考虑一下全球 IT 人员面临的一个现实问题:让用户创建强密码短语。一种方法:强制用户输入最短长度,并附带各种恼人的要求(例如至少一个大写字母、一个数字、一个非字母数字字符等)。

这是一个从用户获取密码短语的 Haskell 函数

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

-- The validation test could be anything we want it to be.
isValid :: String -> Bool
isValid s = length s >= 8
            && any isAlpha s
            && any isNumber s
            && any isPunctuation s

首先,getPassphrase 是一个 IO 操作,因为它需要从用户获取输入。我们也使用 Maybe,因为我们打算在密码不通过 isValid 检查时返回 Nothing。但是请注意,我们实际上并没有在这里将 Maybe 用作 Monad:do 块在 IO Monad 中,我们只是碰巧在其中 return 一个 Maybe 值。

Monad 变换器不仅使编写 getPassphrase 更容易,而且简化了所有代码实例。我们的密码短语获取程序可以继续如下

askPassphrase :: IO ()
askPassphrase = do putStrLn "Insert your new passphrase:"
                   maybe_value <- getPassphrase
                   case maybe_value of
                       Just value -> do putStrLn "Storing in database..."  -- do stuff
                       Nothing -> putStrLn "Passphrase invalid."

代码使用一行生成 maybe_value 变量,然后进一步验证密码短语。

使用 Monad 变换器,我们将能够一次性提取密码短语——无需任何模式匹配(或类似的官僚主义,例如 isJust)。我们这个简单示例的收益可能看起来很小,但对于更复杂的情况来说会成倍增加。

一个简单的 Monad 变换器:MaybeT

[编辑 | 编辑源代码]

为了简化 getPassphrase 和使用它的代码,我们将定义一个Monad 变换器,它赋予 IO Monad 一些 Maybe Monad 的特性;我们将其称为 MaybeT。这遵循了一个约定,即 Monad 变换器在其提供的 Monad 名称后面附加一个“T”。

MaybeTm (Maybe a) 的包装器,其中 m 可以是任何 Monad(在我们的示例中为 IO

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

此数据类型定义指定了一个 MaybeT 类型构造函数,它以 m 为参数,具有一个数据构造函数,也称为 MaybeT,以及一个方便的访问器函数 runMaybeT,我们可以用它来访问底层表示。

Monad 变换器的全部意义在于它们将 Monad 转换为 Monad;因此,我们需要使 MaybeT m 成为 Monad 类的实例

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

  -- The signature of (>>=), specialized to 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

也可以(尽管可能可读性较差)将 return 函数写成:return = MaybeT . return . return

do 块的第一行开始

  • 首先,runMaybeT 访问器将 x 解包成一个 m (Maybe a) 计算。这向我们表明整个 do 块都在 m 中。
  • 仍在第一行,<- 从解包的计算中提取一个 Maybe a 值。
  • case 语句测试 maybe_value
    • 使用 Nothing,我们将 Nothing 返回到 m 中;
    • 使用 Just,我们将 f 应用于来自 Justvalue。由于 f 的结果类型为 MaybeT m b,因此我们需要一个额外的 runMaybeT 将结果放回 m Monad 中。
  • 最后,do 块作为一个整体具有 m (Maybe b) 类型;因此,它使用 MaybeT 构造函数进行包装。

它可能看起来有点复杂,但除了大量的包装和解包之外,MaybeT 的 bind 的实现方式与 Maybe 的熟悉 bind 运算符的实现方式相同

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

为什么在 do 块之前使用 MaybeT 构造函数,而在 do 中使用访问器 runMaybeT?嗯,do 块必须在 m Monad 中,而不是在 MaybeT m 中(此时 MaybeT m 缺少定义的 bind 运算符)。

像往常一样,我们还必须为 MonadApplicativeFunctor 的超类提供实例

instance Monad m => Applicative (MaybeT m) where
    pure = return
    (<*>) = ap

instance Monad m => Functor (MaybeT m) where
    fmap = liftM

此外,使 MaybeT m 成为其他一些类的实例也很方便

instance Monad m => Alternative (MaybeT m) where
    empty   = MaybeT $ return Nothing
    x <|> y = MaybeT $ do maybe_value <- runMaybeT x
                          case maybe_value of
                               Nothing    -> runMaybeT y
                               Just _     -> return maybe_value

instance Monad m => MonadPlus (MaybeT m) where 
    mzero = empty
    mplus = (<|>)

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

MonadTrans 实现 lift 函数,因此我们可以获取来自 m Monad 的函数并将其带入 MaybeT m Monad 以便在 do 块中使用它们。至于 AlternativeMonadPlus,由于 Maybe 是这些类的实例,因此使 MaybeT m 也成为实例是有意义的。

密码短语验证,简化

[编辑 | 编辑源代码]

上面密码短语验证示例现在可以使用 MaybeT Monad 变换器简化如下

getPassphrase :: MaybeT IO String
getPassphrase = do s <- lift getLine
                   guard (isValid s) -- Alternative provides guard.
                   return s

askPassphrase :: MaybeT IO ()
askPassphrase = do lift $ putStrLn "Insert your new passphrase:"
                   value <- getPassphrase
                   lift $ putStrLn "Storing in database..."

代码现在更简单了,尤其是在用户函数 askPassphrase 中。最重要的是,我们不必手动检查结果是 Nothing 还是 Just:bind 运算符会为我们处理这个问题。

请注意我们如何使用 lift 将函数 getLineputStrLn 带入 MaybeT IO Monad。此外,由于 MaybeT IOAlternative 的实例,因此检查密码短语有效性可以通过 guard 语句来处理,该语句在密码短语错误的情况下将返回 empty(即 IO Nothing)。

顺便说一句,借助 MonadPlus,要求用户无限次输入有效密码短语也变得非常容易

askPassphrase :: MaybeT IO ()
askPassphrase = do lift $ putStrLn "Insert your new passphrase:"
                 value <- msum $ repeat getPassphrase
                 lift $ putStrLn "Storing in database..."

在 ghci 上运行新的 askPassphrase 版本很容易

runMaybeT askPassphrase

大量的变换器

[编辑 | 编辑源代码]

变换器包提供了包含许多常用 Monad 变换器的模块(例如,MaybeT 可以在 Control.Monad.Trans.Maybe 中找到)。这些与它们的非变换器版本一致地定义;也就是说,实现基本上相同,只是需要额外的包装和解包才能贯穿其他 Monad。从现在开始,我们将使用前驱 Monad来指代变换器所基于的非变换器 Monad(例如 MaybeT 中的 Maybe),并使用基础 Monad来指代应用变换器的其他 Monad(例如 MaybeT IO 中的 IO)。

举个任意例子,ReaderT Env IO String 是一个计算,它涉及从类型为 Env 的某个环境中读取值(Reader 的语义,即前驱 Monad)并执行一些 IO 以给出类型为 String 的值。由于变换器的 bind 运算符和 return 镜像了前驱 Monad 的语义,因此类型为 ReaderT Env IO Stringdo 块从外部来看,将非常类似于 Reader Monad 的 do 块,除了使用 lift 可以轻松嵌入 IO 操作。

类型转换

[编辑 | 编辑源代码]

我们已经看到 MaybeT 的类型构造函数是基础 Monad 中 Maybe 值的包装器。因此,相应的访问器 runMaybeT 给我们一个类型为 m (Maybe a) 的值 - 即在基础 Monad 中返回的前驱 Monad 的值。类似地,对于 ListTExceptT 变换器,它们分别构建在列表和 Either

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

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

但是,并非所有变换器都与其前驱 Monad 相关联。与上面两个示例中的前驱 Monad 不同,WriterReaderStateCont Monad 既没有多个构造函数,也没有具有多个参数的构造函数。因此,它们有run...函数充当简单的解包器,类似于run...T变换器版本。下表显示了

run...run...T每个案例中的函数,可以被认为分别是基底单子(base monad)和变换单子(transformed monad)分别封装的类型。[1]

前驱单子(Precursor) 变换单子(Transformer) 原始类型(Original Type)
(被前驱单子“封装”)
组合类型(Combined Type)
(被变换单子“封装”)
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

注意,组合类型中缺少前驱单子的类型构造器。如果没有有趣的(interesting)数据构造器(比如 Maybe 和列表所具有的),那么在解开变换单子后就没有理由保留前驱单子的类型。还值得注意的是,在后三个案例中,我们有函数类型被封装。例如,StateT 将形式为 s -> (a, s) 的状态变换函数转换为形式为 s -> m (a, s) 的状态变换函数;只有被封装函数的结果类型进入基底单子。ReaderT 类似。ContT 则有所不同,因为 Cont(延续单子)的语义:被封装函数及其函数参数的结果类型必须相同,因此变换单子将两者都放入基底单子中。一般来说,没有一个神奇的公式可以创建单子的变换版本;每个变换单子的形式取决于在非变换类型上下文中哪些内容是有意义的。

提升(Lifting)

[编辑 | 编辑源代码]

现在我们将更详细地了解 lift 函数,该函数在单子变换的日常使用中至关重要。首先要澄清的是“提升”这个名称。我们已经知道一个具有类似名称的函数是 liftM。正如我们在理解单子中看到的那样,它是 fmap 的特定于单子的版本。

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

liftM 将一个函数 (a -> b) 应用于单子 m 中的值。我们也可以将其视为仅带有一个参数的函数

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

liftM 将一个普通函数转换为在 m 中起作用的函数。通过“提升”,我们指的是将某些东西带入其他东西——在本例中,将一个函数带入单子。

liftM 允许我们对单子值应用普通函数,而无需使用 do 块或其他此类技巧。

绑定表示法(bind notation) do 表示法(do notation) liftM
monadicValue >>= 
   \x -> return (f x)
do x <- monadicValue
   return (f x)
liftM f monadicValue

lift 函数在使用单子变换时起着类似的作用。它将基底单子计算(或使用另一个常用词语,即提升)提升到组合单子中。通过这样做,它允许我们轻松地将基底单子计算作为组合单子中更大计算的一部分插入。

liftMonadTrans 类中的唯一方法,位于 Control.Monad.Trans.Class 中。所有单子变换都是 MonadTrans 的实例,因此 lift 对它们都可用。

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

有一个特定于 IO 操作的 lift 变体,称为 liftIO,它是 Control.Monad.IO.ClassMonadIO 类的唯一方法。

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

当多个变换单子堆叠到一个组合单子中时,liftIO 可能会很方便。在这种情况下,IO 始终是最内部的单子,因此我们通常需要多次提升才能将 IO 值提升到堆栈的顶部。liftIO 为实例定义,以便我们能够一次编写函数,就能将 IO 值从任何深度提升到顶部。

实现 lift

[编辑 | 编辑源代码]

实现 lift 通常非常简单。考虑 MaybeT 变换单子

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

我们从基底单子的单子值开始。使用 liftMfmap 也同样适用),我们将前驱单子(通过 Just 构造器)滑到下面,这样我们就从 m a 变成了 m (Maybe a)。最后,我们使用 MaybeT 构造器将所有内容包装起来。请注意,此处的 liftM 在基底单子中工作,就像我们在早期看到的 (>>=) 的实现中,由 MaybeT 包装的 do 块位于基底单子中一样。

练习
  1. 为什么 lift 函数必须为每个单子单独定义,而 liftM 可以以通用的方式定义?
  2. Identity 是一个简单的函子,在 Data.Functor.Identity 中定义为
    newtype Identity a = Identity { runIdentity :: a }
    它具有以下 Monad 实例
    instance Monad Identity where
        return a = Identity a
        m >>= k  = k (runIdentity m)
    
    实现一个单子变换 IdentityT,类似于 Identity,但封装类型为 m a 的值而不是 a。至少编写其 MonadMonadTrans 实例。

实现变换单子

[编辑 | 编辑源代码]

状态变换单子(The State transformer)

[编辑 | 编辑源代码]

作为一个额外的例子,我们现在将详细了解 StateT 的实现。在继续之前,您可能需要复习一下关于状态单子的部分。

就像状态单子可能基于定义 newtype State s a = State { runState :: (s -> (a,s)) } 构建一样,StateT 变换单子基于以下定义构建

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

StateT s m 将具有以下 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          -- get new value and state
    runStateT (f v) s'     -- pass them to f

我们对 return 的定义利用了基底单子的 return 函数。(>>=) 使用 do 块在基底单子中执行计算。

注意

顺便说一句,我们现在终于可以解释为什么,在关于 State 的章节中,有一个 state 函数而不是 State 构造器。在变换器mtl包中,State s 实现为 StateT s Identity 的类型同义词,其中 Identity 是上一节练习中引入的虚拟单子。生成的单子等效于我们迄今为止一直使用的使用 newtype 定义的单子。


如果组合单子 StateT s m 用作状态单子,我们当然希望拥有非常重要的 getput 操作。在这里,我们将展示 mtl 风格的定义。mtl除了单子变换本身之外,mtl还提供了常用单子基本操作的类型类。例如,位于 Control.Monad.State 中的 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 实例 msStateT s m 共同构成 MonadState 的一个实例”。sm 分别对应于状态和基底单子。s 是实例规范的独立部分,以便方法可以引用它——例如,put 的类型为 s -> StateT s m ()


对于被其他变换单子封装的状态单子,也存在 MonadState 实例,例如 MonadState s m => MonadState s (MaybeT m)。它们为我们带来了额外的便利,使我们无需显式提升 getput 的使用,因为组合单子的 MonadState 实例会为我们处理提升。

将基底单子可能可用的实例提升到组合单子中也可能很有用。例如,所有使用 MonadPlus 实例与 StateT 结合的组合单子都可以成为 MonadPlus 的实例

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

mzeromplus 的实现做了显而易见的事情;也就是说,将实际工作委托给基底单子的实例。

别忘了,单子变换必须具有 MonadTrans,以便我们可以使用 lift

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

lift 函数创建一个 StateT 状态变换函数,该函数将基底单子中的计算绑定到一个函数,该函数将结果与输入状态打包在一起。例如,如果我们将 StateT 应用于 List 单子,则返回列表(即 List 单子中的计算)的函数可以提升到 StateT s [] 中,在那里它成为一个返回 StateT (s -> [(a,s)]) 的函数。即提升后的计算从其输入状态产生多个(值,状态)对。这将 StateT 中的计算“分叉”,为列表中返回的每个值创建不同的计算分支。当然,将 StateT 应用于不同的单子将为 lift 函数产生不同的语义。

练习
  1. getput 来实现 state :: MonadState s m => (s -> (a, s)) -> m a
  2. MaybeT (State s)StateT s Maybe 是否等价?(提示:一种方法是在每种情况下比较 run...T 解包器产生的结果。)

本模块使用了一些摘录自关于单子的所有内容,经作者Jeff Newbern许可。

注释

  1. 包装解释仅在2.0.0.0版本之前的mtl包中严格适用。
华夏公益教科书