Haskell/Monad 变换器
我们已经了解了 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)。我们这个简单示例的收益可能看起来很小,但对于更复杂的情况来说会成倍增加。
为了简化 getPassphrase 和使用它的代码,我们将定义一个Monad 变换器,它赋予 IO Monad 一些 Maybe Monad 的特性;我们将其称为 MaybeT。这遵循了一个约定,即 Monad 变换器在其提供的 Monad 名称后面附加一个“T”。
MaybeT 是 m (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应用于来自Just的value。由于f的结果类型为MaybeT m b,因此我们需要一个额外的runMaybeT将结果放回mMonad 中。
- 使用
- 最后,
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 运算符)。
像往常一样,我们还必须为 Monad、Applicative 和 Functor 的超类提供实例
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 块中使用它们。至于 Alternative 和 MonadPlus,由于 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 将函数 getLine 和 putStrLn 带入 MaybeT IO Monad。此外,由于 MaybeT IO 是 Alternative 的实例,因此检查密码短语有效性可以通过 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 String 的 do 块从外部来看,将非常类似于 Reader Monad 的 do 块,除了使用 lift 可以轻松嵌入 IO 操作。
我们已经看到 MaybeT 的类型构造函数是基础 Monad 中 Maybe 值的包装器。因此,相应的访问器 runMaybeT 给我们一个类型为 m (Maybe a) 的值 - 即在基础 Monad 中返回的前驱 Monad 的值。类似地,对于 ListT 和 ExceptT 变换器,它们分别构建在列表和 Either 上
runListT :: ListT m a -> m [a]
和
runExceptT :: ExceptT e m a -> m (Either e a)
但是,并非所有变换器都与其前驱 Monad 相关联。与上面两个示例中的前驱 Monad 不同,Writer、Reader、State 和 Cont Monad 既没有多个构造函数,也没有具有多个参数的构造函数。因此,它们有run...函数充当简单的解包器,类似于run...T变换器版本。下表显示了
| 前驱单子(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(延续单子)的语义:被封装函数及其函数参数的结果类型必须相同,因此变换单子将两者都放入基底单子中。一般来说,没有一个神奇的公式可以创建单子的变换版本;每个变换单子的形式取决于在非变换类型上下文中哪些内容是有意义的。
现在我们将更详细地了解 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 函数在使用单子变换时起着类似的作用。它将基底单子计算(或使用另一个常用词语,即提升)提升到组合单子中。通过这样做,它允许我们轻松地将基底单子计算作为组合单子中更大计算的一部分插入。
lift 是 MonadTrans 类中的唯一方法,位于 Control.Monad.Trans.Class 中。所有单子变换都是 MonadTrans 的实例,因此 lift 对它们都可用。
class MonadTrans t where
lift :: (Monad m) => m a -> t m a
有一个特定于 IO 操作的 lift 变体,称为 liftIO,它是 Control.Monad.IO.Class 中 MonadIO 类的唯一方法。
class (Monad m) => MonadIO m where
liftIO :: IO a -> m a
当多个变换单子堆叠到一个组合单子中时,liftIO 可能会很方便。在这种情况下,IO 始终是最内部的单子,因此我们通常需要多次提升才能将 IO 值提升到堆栈的顶部。liftIO 为实例定义,以便我们能够一次编写函数,就能将 IO 值从任何深度提升到顶部。
实现 lift 通常非常简单。考虑 MaybeT 变换单子
instance MonadTrans MaybeT where
lift m = MaybeT (liftM Just m)
我们从基底单子的单子值开始。使用 liftM(fmap 也同样适用),我们将前驱单子(通过 Just 构造器)滑到下面,这样我们就从 m a 变成了 m (Maybe a)。最后,我们使用 MaybeT 构造器将所有内容包装起来。请注意,此处的 liftM 在基底单子中工作,就像我们在早期看到的 (>>=) 的实现中,由 MaybeT 包装的 do 块位于基底单子中一样。
| 练习 |
|---|
|
作为一个额外的例子,我们现在将详细了解 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 用作状态单子,我们当然希望拥有非常重要的 get 和 put 操作。在这里,我们将展示 mtl 风格的定义。mtl除了单子变换本身之外,mtl还提供了常用单子基本操作的类型类。例如,位于 Control.Monad.State 中的 MonadState 类,具有 get 和 put 作为方法
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 实例 m,s 和 StateT s m 共同构成 MonadState 的一个实例”。s 和 m 分别对应于状态和基底单子。s 是实例规范的独立部分,以便方法可以引用它——例如,put 的类型为 s -> StateT s m ()。
对于被其他变换单子封装的状态单子,也存在 MonadState 实例,例如 MonadState s m => MonadState s (MaybeT m)。它们为我们带来了额外的便利,使我们无需显式提升 get 和 put 的使用,因为组合单子的 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)
mzero 和 mplus 的实现做了显而易见的事情;也就是说,将实际工作委托给基底单子的实例。
别忘了,单子变换必须具有 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 函数产生不同的语义。
| 练习 |
|---|
|
本模块使用了一些摘录自关于单子的所有内容,经作者Jeff Newbern许可。
注释
- ↑ 包装解释仅在2.0.0.0版本之前的mtl包中严格适用。