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
将结果放回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 运算符)。
像往常一样,我们还必须为 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包中严格适用。