第 15 章:使用 Monad 编程

优质
小牛编辑
133浏览
2023-12-01

高尔夫训练:关联列表

Web客户端和服务器通常通过简单的文本键值对列表来传输消息,例如:

name=Attila+%42The+Hun%42&occupation=Khan

这种编码方式被称作 application/x-www-form-urlencoded,这种方式非常容易理解:每个键值对通过 & 划分。在一个键值对中,键由一系列 URL 编码字符构成,键后紧跟着 = 和值(如果存在的话)。

很明显我们可以用一个 String 来表示键,但 HTTP 没有明确指出一个键是否必须有对应的值。我们可以将值用 Maybe String 表示以捕获歧义,当我们使用 Nothing 时,值不存在,当我们用 Just 包装一个 String 时,值存在。使用 Maybe 允许我们区分“值不存在”和“空值”。[译注: application/x-www-form-urlencoded 实际是在 HTML 的 Forms 部分定义的,而非原著中的 HTML]

Haskell程序员使用 关联列表 表示类型 [(a, b)],你可以把列表中的每个元素理解为一个键和值的关联。关联列表的名称起源于 Lisp 社区,通常缩写为列表,因此我们可以将上述字符串表示为以下的 Haskell 值。

-- file: ch15/MovieReview.hs
[("name",       Just "Attila \"The Hun\""),
 ("occupation", Just "Khan")]

在 parsing-an-url-encoded-query-string 中,我们将解析一个 application/x-www-form-urlencoded 编码的字符串,并使用形如 [(String, Maybe String)] 的关联列表表示结果。假设我们想使用这些列表中的一个来填充一个数据结构。

-- file: ch15/MovieReview.hs
data MovieReview = MovieReview {
          revTitle :: String
    , revUser :: String
, revReview :: String
        }

我们首先用一个简单的函数说明:

-- file: ch15/MovieReview.hs
simpleReview :: [(String, Maybe String)] -> Maybe MovieReview
simpleReview alist =
  case lookup "title" alist of
        Just (Just title@(_:_)) ->
          case lookup "user" alist of
                Just (Just user@(_:_)) ->
                  case lookup "review" alist of
                        Just (Just review@(_:_)) ->
                                Just (MovieReview title user review)
                        _ -> Nothing -- no review
                _ -> Nothing -- no user
        _ -> Nothing -- no title

当且仅当作为参数的关联列表包含了所有必须的键值对,并且这些键值对中的值不为空时,函数 simpleReview 将返回一个 MovieReview 。然而,它的优点仅仅是能够验证输入是否合法,实际上它采用了应当尽量避免的“锯齿型(staircasing)”代码结构,并且它过于了解关联列表的表示细节。

我们已经对 Maybe monad 非常熟悉了,上面的代码可以整理一下,让它避免“锯齿化”结构。

-- file: ch15/MovieReview.hs
maybeReview alist = do
        title <- lookup1 "title" alist
        user <- lookup1 "user" alist
        review <- lookup1 "review" alist
        return (MovieReview title user review)

lookup1 key alist = case lookup key alist of
                                          Just (Just s@(_:_)) -> Just s
                                          _ -> Nothing

代码看起来整洁了许多,但其中仍存在重复的工作。我们可以利用 MovieReview 构造器是普通纯函数的性质,将其提升为 monad,就像我们在 同时使用puer和monadic代码 讨论过的那样。

-- file: ch15/MovieReview.hs
liftedReview alist =
        liftM3 MovieReview (lookup1 "title" alist)
                                           (lookup1 "user" alist)
                                           (lookup1 "review" alist)

在上面这段代码中依旧存在很多重复的工作,但它们已经显著减少了,并且我们很难去除剩下的部分。

广义的提升

虽然使用 liftM3 让我们的代码更加整洁,但我们不能用一堆 liftM 去解决更广泛的问题,因为标准库只定义到了 liftM5。事实上我们可以根据我们的需要写出任意数字的 liftM ,但那将是非常繁重的工作。

假设我们有一个构造器或者纯函数,并且接受 10 个参数,这时候再坚持用标准库,你恐怕就觉得我们没有那么好运了。

当然,标准库里面还有其他工具可用,在 Control.Monad 中,有一个函数 ap,它的类型签名(type signature)非常有趣。

ghci> :m +Control.Monad
ghci> :type ap
ap :: (Monad m) => m (a -> b) -> m a -> m b

你可能会觉得奇怪,谁会把一个接受单一参数的纯函数放到 monad 中,这么做的原因又是什么?回想一下,其实所有的 Haskell 函数本质上都是接受单一参数,MovieReview 的构造器也是这样。

ghci> :type MovieReview
MovieReview :: String -> String -> String -> MovieReview

我们可以将类型签名写成 String -> (String -> String -> MovieReview)。假如我们使用 liftMMovieReview 提升为 Maybe monad,我们将得到一个类型为 Maybe (String -> (String -> (String -> MovieReview)))``的值。这个类型恰好是 ``ap 接受的参数的类型,并且 ap 的返回类型将是 Maybe (String -> (String -> MovieReview))。我们可以将 ap 返回的值继续传入 ap ,直到我们结束这个定义。

-- file: ch15/MovieReview.hs
apReview alist =
        MovieReview `liftM` lookup1 "title" alist
                                   `ap` lookup1 "user" alist
                                   `ap` lookup1 "review" alist

Warning

[译注:原著这里存在错误,上面的译文直接翻译了原著,在此做出修正。使用 liftMMovieReview 提升为 Maybe monad后,得到的类型不是 Maybe (String -> (String -> (String -> MovieReview))),而是 Maybe String -> Maybe (String -> (String -> MovieReview))。下面给出在不断应用 ap 时,类型系统的显示变化过程。]

-- note by translator
        MovieReview :: String -> ( String -> String -> MovieRevie )
        MovieReview `liftM` :: Maybe String -> Maybe ( String -> String -> MovieRevie )
        MovieReview `liftM` lookup1 "title" alist :: Maybe ( String -> String -> MovieRevie )
        MovieReview `liftM` lookup1 "title" alist `ap` :: Myabe String -> Maybe ( String -> MovieRevie )
        MovieReview `liftM` lookup1 "title" alist `ap` lookup1 "user" alist :: Maybe ( String -> MovieRevie )

我们可以通过不断应用 ap 来替代 liftM 的一系列函数。

这样理解 ap 可能会对你有所帮助:ap 的 monadic 等价于我们熟悉的 ($) 运算符,你可以想象一下把 ap 读成 apply。通过观察这二者的类型签名,我们可以清晰地看到这一点。

ghci> :type ($)
($) :: (a -> b) -> a -> b
ghci> :type ap
ap :: (Monad m) => m (a -> b) -> m a -> m b

事实上,ap 通常被定义为 liftM2 id 或者 liftM2 ($)。[译注:如果你使用 :t 来观察这两种书写形式得到的类型签名,你会发现它们在类型细节上有所差异,这是由 id($) 本身类型签名的不同导致的,id``的签名是``a -> a,而 ($)(a -> b) -> (a -> b),当然这对于广义的类型是等同的。]

寻找替代方案

下面是通讯录中一项的简单表示。

-- file: ch15/VCard.hs
data Context = Home | Mobile | Business
                           deriving (Eq, Show)

type Phone = String

albulena = [(Home, "+355-652-55512")]

nils = [(Mobile, "+47-922-55-512"), (Business, "+47-922-12-121"),
                (Home, "+47-925-55-121"), (Business, "+47-922-25-551")]

twalumba = [(Business, "+260-02-55-5121")]

假设我们想给某个人打一个私人电话,我们必然会选择他的家庭号码(假如他有的话),而不是他的工作号码。

-- file: ch15/VCard.hs
onePersonalPhone :: [(Context, Phone)] -> Maybe Phone
onePersonalPhone ps = case lookup Home ps of
                                                Nothing -> lookup Mobile ps
                                                Just n -> Just n

在上面的代码中,我们使用 Maybe 作为生成结果的类型,这样无法处理某个人有多个符合要求的号码的情况。因此,我们将返回类型转换为一个列表。

-- file: ch15/VCard.hs
allBusinessPhones :: [(Context, Phone)] -> [Phone]
allBusinessPhones ps = map snd numbers
        where numbers = case filter (contextIs Business) ps of
                                          [] -> filter (contextIs Mobile) ps
                                          ns -> ns

contextIs a (b, _) = a == b

注意,这两个函数中 case 表达式的结构非常相似:其中一个标签处理查找结果为空的情况,剩下的处理结果非空的情况。

ghci> onePersonalPhone twalumba
Nothing
ghci> onePersonalPhone albulena
Just "+355-652-55512"
ghci> allBusinessPhones nils
["+47-922-12-121","+47-922-25-551"]

[译注:这里的代码通过需要 :l 导入 VCard.hs ]

Haskell 的 Control.Monad 模块定义了一种类型类 MonadPlus ,这使我们可以将 case 表达式中的普通模式抽象出来。

-- file: ch15/VCard.hs
class Monad m => MonadPlus m where
   mzero :: m a
   mplus :: m a -> m a -> m a

mzero 表示了一个空结果, mplus 将两个结果合并为一个。下面是 mzeromplus 针对 Maybe 和列表的标准定义。[译注:在约翰·休斯 1998 年发表的《Generalising Monads to Arrows》中,他提出 mzero 可理解为对失败情况的一种概括,而 mplus 则是对选择情况的概括,例如如果第一种情况失败,则尝试第二种。]

-- file: ch15/VCard.hs
instance MonadPlus [] where
   mzero = []
   mplus = (++)

instance MonadPlus Maybe where
   mzero = Nothing

   Nothing `mplus` ys  = ys
   xs      `mplus` _ = xs

我们现在可以使用 mplus 替换掉整个 case 表达式。为了照顾情况的多样性,我们下面来获取通讯录中某人的一个工作号码和他所有的私人号码。

-- file: ch15/VCard.hs
oneBusinessPhone :: [(Context, Phone)] -> Maybe Phone
oneBusinessPhone ps = lookup Business ps `mplus` lookup Mobile ps

allPersonalPhones :: [(Context, Phone)] -> [Phone]
allPersonalPhones ps = map snd $ filter (contextIs Home) ps `mplus`
                                                                 filter (contextIs Mobile) ps

[译注:在前面的例子中,我们将 mplus 作为 case 模式的一种抽象表达来介绍,但是对于 list monad,它会产生和前面例子不同的结果。考虑前面的例子 allBusinessPhones,我们试图获取一个人的全部工作号码,当且仅当他没有工作号码时,结果中才包含私人号码。而 mplus 只是将全部工作号码和私人号码连接在一起,这和我们想要的结果有出入。]

我们已经知道 lookup 会返回一个 Maybe 类型的值,而 filter 将返回一个列表,所以对于这些函数,应当使用什么版本的 mplus 是非常显然的。

更有趣的是我们现在可以使用 mzeromplus 来编写对任意 MonadPlus 实例均有效的函数。举例而言,下面是一个标准的 lookup 函数,它将返回一个 Maybe 类型的值。

-- file: ch15/VCard.hs
lookup :: (Eq a) => a -> [(a, b)] -> Maybe b
lookup _ []                      = Nothing
lookup k ((x,y):xys) | x == k    = Just y
                                         | otherwise = lookup k xys

通过下面的代码,我们可以很容易的将结果类型推广到 MonadPlus 的任意实例。

-- file: ch15/VCard.hs
lookupM :: (MonadPlus m, Eq a) => a -> [(a, b)] -> m b
lookupM _ []    = mzero
lookupM k ((x,y):xys)
        | x == k    = return y `mplus` lookupM k xys
        | otherwise = lookupM k xys

假如我们得到的结果是 Maybe 类型,那么通过这种方式我们将得到一个结果或“没有结果”;假如我们得到的结果是一个列表,那么我们将获得所有的结果;其它情况下,我们将获得一些适用于其它 MonadPlus 实例的结果。

对于一些类似我们上面展示的小函数,使用 mplus 没什么明显的优点。 mplus 的优点体现在更复杂的代码和那些独立于 monad 执行过程的代码中。即使你没有在自己的代码中碰到需要使用 MonadPlus 的情况,你也很可能在别人的项目中遇到它。

mplus 不意味着相加

函数 mplus 的名字中包含了 “plus”,但这并不代表着我们一定是要将两个值相加。根据我们处理的 monad 的不同,有时 mplus 会实现看起来类似相加的操作。例如,列表 monad 中 mplus 等同于 (++) 运算符。

ghci> [1,2,3] `mplus` [4,5,6]
[1,2,3,4,5,6]

但是,假如我们切换到另一个 monad, mplus 和加法操作将不存在明显的相似性。

ghci> Just 1 `mplus` Just 2
Just 1

使用 MonadPlus 的规则

除了通常情况下 monad 的规则外, MonadPlus 类型类的实例必须遵循一些其他简单的规则。

如果一个捆绑表达式左侧出现了 mzero ,那么这个实例必须短路(short circuit)。换句话说,表达式 mzero >>= f 必须和单独的 mzero 效果相同。[译注:“短路” 也用来描述严格求值语言中布尔运算符的“短路”特性,例如 B != null && B.value != "" 可以避免在 B == null 时考量 B.value ]

-- file: ch15/MonadPlus.hs
        mzero >>= f == mzero

如果 mzero 出现在了一个序列表达式的右侧,则这个实例必须短路。[译注:此处存在争议,例如 Maybe monad的一个例子 (undefined >> Nothing) = undefined /= Nothing 不满足这一条件。一种观点认为,短路特性意味着如果表达式中某个操作数的结果为某事,则不评估另一个操作数,也就是说必须首先评估一个操作数。所以,在“从左向右”和“从右向左”的短路之间,只能存在一种。]

-- file: ch15/MonadPlus.hs
        v >> mzero == mzero

通过 MonadPlus 安全地失败

当我们在 Monad 类型类 中介绍 fail 函数时,我们对它的使用提出了警告:在许多 monad 中,它可能被实现为一个对错误的调用,这会导致令人不愉快的后果。

MonadPlus 类型类为我们提供了一种更温和的方法来使一个计算失败,这使我们不必面临使用 failerror 带来的危险。上面介绍的规则允许我们在代码中需要的任何地方引入一个 mzero ,这样计算将在该处短路。

Control.Monad 模块中,标准函数 guard 将这个想法封装成了一种方便的形式。

-- file: ch15/MonadPlus.hs
guard        :: (MonadPlus m) => Bool -> m ()
guard True   =  return ()
guard False  =  mzero

作为一个简单的例子,这里有一个函数,它接受一个数 x 作为参数,并计算 x 对于另一个数 n 的取模结果。假如结果是 0 则返回 x ,否则返回当前 monad 对应的 mzero

-- file: ch15/MonadPlus.hs
x `zeroMod` n = guard ((x `mod` n) == 0) >> return x

隐藏管道

使用State monad生成随机数 中,我们展示了使用 State monad 生成随机数的简单方法。

我们编写的代码的一个缺点是它泄露了细节:使用者知道代码运行在 State monad中。这意味着他们可以像我们这些作者一样检测并修改随机数生成器的状态。

人的本性决定了,一旦我们将工作内部细节暴露出来,就会有人试图对其做手脚。对于一个足够小的程序,这也许没什么问题,但在更大的软件项目中,如果库的某个使用者使用了其他使用者都没有预料到的方式修改库,这一举动可能导致的错误将非常严重。因为问题出现在库中,而我们通常不会怀疑库有问题,所以这些错误很难被发现,直到我们排除了所有其他可能。

更糟糕的是,一旦程序的实现细节暴露,一些人将绕过我们提供的 API 并直接采用内部实现方式。当我们需要修复某个错误或者增强某个功能时,我们等于为自己设置了一道屏障。我们要么修改内部、破坏依赖它们的代码,要么坚持现有的内部结构并寻找其他方式来做出需要的改变。

我们该如何修改随机数 monad 来隐藏我们使用了 State monad 的事实?我们需要使用某种方式来阻止用户调用 getput 。要实现这一点并不难,并且在具体实现中我们将会介绍一些在日常 Haskell 编程中经常使用的技巧。

为了扩大应用的范围,我们不用随机数做例子,而是实现了一个可以提供任意类型不重复值的 monad,这个 monad 叫做 Supply 。我们将为执行函数 runSupply 提供一个值的列表,并确保列表中每个值是独一无二的。

-- file: ch15/Supply.hs
runSupply :: Supply s a -> [s] -> (a, [s])

这个 monad 并不关心这些值是什么,它们可能是随机数,或临时文件的名称,或者是 HTTP cookie的标识符。

在这个 monad 中,每当用户要求获取一个值时, next 就会从列表中取出下一个值并将其交给用户。每个值都被 Maybe 构造器包装以防止这个列表的长度不满足需求。

-- file: ch15/Supply.hs
next :: Supply s (Maybe s)

为了隐藏我们的管道,在模块声明中我们只导出了类型构造函数,执行函数和 next 动作。

-- file: ch15/Supply.hs
module Supply
        (
          Supply
        , next
        , runSupply
        ) where

因为导入库的模块不能看到 monad 的内部,所以它不能修改我们的库。

我们的管道非常简单:使用一个 newtype 声明来包装现有的 State monad。

-- file: ch15/Supply.hs
import Control.Monad.State

newtype Supply s a = S (State [s] a)

参数 s 是我们提供的独特值的类型, a 是我们必须提供的常见类型参数,以使我们的类型成为 monad。[译注:这里类型 a 是自由的,即 a 可以是任何东西,以允许 monadic 函数返回任何可能需要的类型。例如 hGetLine :: Handle - > IO String 这样一个 monadic 函数,给定一个文件句柄,将从它读取一行并返回这一行的内容。这里,String 是 IO Monad 要返回的类型 a ,程序员可以将 hGetLine 看作从句柄读取 String 的函数。]

我们通过在 Supply 类型上应用 newtype 以及定义模块头来阻止用户使用 State monad 的 getset 动作。因为我们的模块并不导出 s 的构造器,所以用户没有程序化的方式来查看或访问包装在 State monad 中的内容。

现在我们有了一个类型 Supply ,接下来需要定义一个 Monad 类型类的实例。我们可以遵循通常的方式如 (>>=)return,但这将变成纯样板代码。我们现在所做的是通过 s 值构造器将 State monad 版本的 (>>=)return 包装和展开。代码看起来应该是这个样子:

-- file: ch15/AltSupply.hs
unwrapS :: Supply s a -> State [s] a
unwrapS (S s) = s

instance Monad (Supply s) where
        s >>= m = S (unwrapS s >>= unwrapS . m)
        return = S . return

Haskell 程序员不喜欢样板,GHC 有一个可爱的语言拓展功能消除了这一工作。我们将以下指令添加到源文件顶部(模块头之前)来使用这一功能。

-- file: ch15/Supply.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

通常我们只能自动导出一些标准类型类的实例如 ShowEq 。顾名思义, GeneralizedNewtypeDeriving 拓展了我们派生类型类实例的能力,并且它特定于 newtype 的声明。通过下面的方式,如果我们包装的类型是任意一个类型类的实例,这个拓展可以自动让我们的新类型成为该类型类的实例。

-- file: ch15/Supply.hs
        deriving (Monad)

[译注:在 GHC 7.10 中, Monad` 是 ``ApplicativeFunctor 的子类,因此上面的 deriving 需要改为 deriving(Functor, Applicative, Monad) 。]

这需要底层类型实现 (>>=)return ,通过 s 值构造器添加必要的包装和展开方法,并使用这些函数的新版本为我们导出一个 Monad 实例。[译注:这里的底层类型指的是 State 。]

我们在这里获得到的远比这个例子来的多。我们可以使用 newtype 来包装任何底层类型;我们选择性地只暴露符合我们想法的类型类实例;而且我们几乎没有花费更多的工作来创建这些更恰当、专业的类型。

现在我们已经看到了 GeneralizedNewtypeDeriving 技术,剩下的工作就是提供 nextrunSupply 的定义。

-- file: ch15/Supply.hs
next = S $ do st <- get
                          case st of
                                [] -> return Nothing
                                (x:xs) -> do put xs
                                                         return (Just x)

runSupply (S m) xs = runState m xs

我们可以将模块导入 ghci ,并用几种简单的方式尝试:

ghci> :load Supply
[1 of 1] Compiling Supply           ( Supply.hs, interpreted )
Ok, modules loaded: Supply.
ghci> runSupply next [1,2,3]
Loading package mtl-1.1.0.0 ... linking ... done.
(Just 1,[2,3])
ghci> runSupply (liftM2 (,) next next) [1,2,3]
((Just 1,Just 2),[3])
ghci> runSupply (liftM2 (,) next next) [1]
((Just 1,Nothing),[])

我们也可以验证 State monad 是否以某种方式泄露。

ghci> :browse Supply
data Supply s a
next :: Supply s (Maybe s)
runSupply :: Supply s a -> [s] -> (a, [s])
ghci> :info Supply
data Supply s a         -- Defined at Supply.hs:17:8-13
instance Monad (Supply s) -- Defined at Supply.hs:17:8-13

提供随机数

如果我们想使用 Supply monad 作为随机数的源,那么有一些小困难需要克服。理想情况下,我们希望能为它提供一个无限的随机数流。我们可以在 IO monad 中获得一个 StdGen ,但完成后必须 “放回” 一个不同的 StdGen 。假如我们不这么做,下一段代码得到的 StdGen 将获得与之前相同的状态。这意味着它将产生与此前相同的随机数,这样的结果可能是灾难性的。

目前为止我们所看到的 System.Random 模块很难满足这些要求。我们可以使用 getStdRandom ,它的类型确保了我们可以同时得到和放回一个 StdGen

我们可以使用 random 在获取一个随机数的同时得到一个新的 StdGen 。我们可以用 randoms 获取一个无限的随机数列表。但我们如何同时得到一个无限的随机数列表和一个新的 StdGen

答案在于 RandomGen 类型类的拆分函数。它接受一个随机数生成器,并将其转换为两个生成器。能够分裂这样的随机生成器是一件很不寻常的事,它在纯函数的设定中显然非常有用,但对于非纯函数语言基本不需要。[译注: stdSplit 的统计基础较差,如果随机数的质量很重要,应当尽量避免不必要的分割。]

通过使用 split 函数,我们可以用一个 StdGen 来生成一个无限长的随机数列表并将其交付 runSupply ,同时将另一个 StdGen 返还给 IO monad。

-- file: ch15/RandomSupply.hs
import Supply
import System.Random hiding (next)

randomsIO :: Random a => IO [a]
randomsIO =
        getStdRandom $ \g ->
                let (a, b) = split g
                in (randoms a, b)

如果我们正确的书写了这个函数,我们的例子应该在每次调用时打印一个不同的随机数。

ghci> :load RandomSupply
[1 of 2] Compiling Supply           ( Supply.hs, interpreted )
[2 of 2] Compiling RandomSupply     ( RandomSupply.hs, interpreted )
Ok, modules loaded: RandomSupply, Supply.
ghci> (fst . runSupply next) `fmap` randomsIO

<interactive>:1:17:
        Ambiguous occurrence `next'
        It could refer to either `Supply.next', imported from Supply at RandomSupply.hs:4:0-12
                                                                                          (defined at Supply.hs:32:0)
                                                  or `System.Random.next', imported from System.Random
ghci> (fst . runSupply next) `fmap` randomsIO

<interactive>:1:17:
        Ambiguous occurrence `next'
        It could refer to either `Supply.next', imported from Supply at RandomSupply.hs:4:0-12
                                                                                          (defined at Supply.hs:32:0)
                                                  or `System.Random.next', imported from System.Random

Warning

[译注:此处保留了原著中的错误,原著执行的代码中可能丢失了 hiding (next) ,因此产生歧义。下面给出正确情况下的某次执行结果。]

ghci> :load RandomSupply
[1 of 2] Compiling Supply           ( Supply.hs, interpreted )
[2 of 2] Compiling Main             ( RandomSupply.hs, interpreted )
Ok, modules loaded: Supply, Main.
ghci> (fst . runSupply next) `fmap` randomsIO
Just (-54705384517081531)
ghci> (fst . runSupply next) `fmap` randomsIO
Just (-2652939136952789000)
ghci> (fst . runSupply next) `fmap` randomsIO
Just (-5089130856647223466)

回想一下,我们的 runSupply 函数同时返回执行 monadic 操作的结果和列表的剩余部分。因为我们传递了一个无限的随机数列表,所以这里用 fst 来组合,以保证当 ghci 尝试打印结果时不会被随机数淹没。

另一轮高尔夫训练

这种将函数应用在一对元素的其中一个元素上,并且与另一个未被修改的原始元素构成新对的模式在 Haskell 代码中已经非常普遍,它已经成为了一种标准代码。

Control.Arrow 模块中有两个函数 firstsecond ,它们执行这个操作。

ghci> :m +Control.Arrow
ghci> first (+3) (1,2)
(4,2)
ghci> second odd ('a',1)
('a',True)

(事实上我们已经在 JSON类型类,不带有重叠实例 中遇到过 second 了。)我们可以使用 first 来产生我们自己 randomsIO 的定义,将其转化为单线形式。

-- file: ch15/RandomGolf.hs
import Control.Arrow (first)

randomsIO_golfed :: Random a => IO [a]
randomsIO_golfed = getStdRandom (first randoms . split)

将接口与实现分离

在前面的章节中,我们看到了如何向用户隐藏我们在内部使用 State monad 来保持 Supply monad状态的事实。

使代码更加模块化的另一个重要方式是将接口(代码可以做什么)从实现(代码具体如何做)中分离出来。

众所周知,标准的随机数生成器 System.Random 速度很慢。如果使用我们的 randomsIO 函数为它提供随机数,那么我们的 next 动作执行效果将不会很好。

一个简单有效的解决办法是为 Supply 提供一个更好的随机数源,但现在让我们把这个方法先放到一边,转而考虑一种在许多设定中都有效的替代方法。我们将使用一个类型类,对 monad 的行为和实现进行分离。

-- file: ch15/SupplyClass.hs
class (Monad m) => MonadSupply s m | m -> s where
        next :: m (Maybe s)

这个类型类定义了任何 Supply monad 都必须实现的接口。因为这个类型类使用了几个我们尚不熟悉的 Haskell 语言扩展,所以我们需要对这个类型类进行详细的分析,它涉及的各个扩展将在接下来的小节中介绍。

多参数类型类

我们应该如何读取类型类中的代码片段 MonadSupply s m ?如果我们添加括号,则等价表达式是 ( MonadSupply s ) m ,它看起来更清晰一些。换句话说,给定一些 Monad 类型变量 m ,我们可以让它成为类型类 MonadSupply s 的一个实例。与常规类型类不同,这里的类型类有一个参数。 [译注: 此链接 可能会帮助你更好地理解功能依赖及多参数类型类。实际上,上面给出的例子中, sm 仅仅是两个参数,它们的位置可以互换。原作者通过加括号的方式,将其理解为一个参数和另一个有参数的类型类,也许在数学上正确,但译者认为此处可能导致读者的困惑。]

这个语言扩展被称作 MultiParamTypeClasses ,因为它允许类型类有多个参数。参数 s 的作用与 Supply 类型的参数相同:它代表了 next 动作发出的值。

注意,我们不需要在 MonadSupply s 的定义中提到 (>>=)return ,因为类型类的上下文(超类)要求 MonadSupply s 必须已经是一个 Monad

功能依赖

现在让我们回头看之前被忽略的代码段, | m -> s 是一个功能依赖,通常被称作 fundep 。我们可以将竖线 | 读作 “这样”,将箭头 -> 读作“唯一确定”。我们的功能依赖建立了 ms 之间的关系。

功能依赖由 FunctionalDependencies 语言编译指令管理。

我们声明 ms 之间关系的目的是帮助类型检查器。回想一下,Haskell类型检查器本质上是一个定理证明器,并且它在操作上是保守的:它坚持这个证明过程必须终止。一个非终止的证明结果将导致编译器放弃或陷入无限循环。

通过功能依赖,我们告诉类型检查器,一旦它看到一些 monad mMonadSupply s 的上下文中被使用,那么类型 s 就是唯一可以接受的类型。假如我们省略功能依赖,类型检查器就会放弃并返回一个错误消息。

我们很难描述 ms 之间的关系究竟是什么,所以让我们来看一个该类型类具体的实例。

-- file: ch15/SupplyClass.hs
import qualified Supply as S

instance MonadSupply s (S.Supply s) where
        next = S.next

这里,类型变量 m 由类型 S.Supply s 替换。因为功能依赖的存在,类型检查器知道当它看到类型 S.Supply s 时, 这个类型可以被当作类型类 MonadSupply s 的实例来使用。

假如没有功能依赖,类型检查器不会发现 MonadSupply sSupply s 二者类型参数之间的关系,因此它将终止编译并产生一个错误。它们的定义本身将会编译,而类型错误直到我们尝试使用它的时候才会产生。

下面我们用一个例子剥离最后一层抽象:考虑类型 S.Supply Int 。我们可以不使用功能依赖而将其声明为 MonadSupply s 的一个实例。但是假如我们试图使用这个实例编写代码,编译器将无法得知类型的 Int 参数需要和类型类 s 的参数相同,因此它将报告一个错误。

功能依赖可能难以理解,并且它们被证明在实践中通常很难使用。幸运的是,功能依赖一般在和我们例子类似的简单情况下使用,此时它们不会导致什么麻烦。

舍入模块

假如我们将类型类和实例保存在名为 SupplyClass.hs 的源文件中,那么就需要在文件中添加一个类似这样的模块头:

-- file: ch15/SupplyClass.hs
{-# LANGUAGE FlexibleInstances, FunctionalDependencies,
                         MultiParamTypeClasses #-}

module SupplyClass
        (
          MonadSupply(..)
        , S.Supply
        , S.runSupply
        ) where

这个 FlexibleInstances 扩展是必须的,否则编译器不会接受我们的实例声明。这个扩展放宽了在某些情况下书写实例的一般规则,但同时仍然让编译器的类型检查器保证它的推导会结束。我们这里需要 FlexibleInstances 的原因是我们使用了功能依赖,具体的细节超过了本书所讨论的范畴。

Note

如何知道是否需要一个语言扩展

假如 GHC 因为需要启用某些语言扩展而不能编译一段代码,它会提醒我们哪些扩展需要使用。比如,假如它认为我们的代码需要 flexible 的实例支持,它将提醒我们使用 -XFlexibleInstances 选项编译。 -x 选项和 LANGUAGE 伪指令具有相同的效果:它们都可以启用一个特定的扩展。

最后,请注意我们正在从此模块中重新导出 runSupplySupply 的名称。从一个模块中导出名称是完全合法的,即使它在另一个模块中被定义。在我们的例子中,这意味着客户端代码只需要导入 SupplyClass 模块,而不需要导入 Supply 模块。这减少了用户需要记住的 “移动部件” 的数量。

对 monad 接口编程

下面是一个简单的函数,它从我们的 Supply monad 中获取两个值,将它们格式化为字符串,并返回它们。

-- file: ch15/Supply.hs
showTwo :: (Show s) => Supply s String
showTwo = do
  a <- next
  b <- next
  return (show "a: " ++ show a ++ ", b: " ++ show b)

这份代码通过它的结果类型绑定到我们的 Supply monad。通过修改函数类型,我们可以在维持函数主体不变的情况下,将此方法推广至所有实现了 MonadSupply 接口的 monad。

-- file: ch15/SupplyClass.hs
showTwo_class :: (Show s, Monad m, MonadSupply s m) => m String
showTwo_class = do
  a <- next
  b <- next
  return (show "a: " ++ show a ++ ", b: " ++ show b)

Reader monad

State monad 让我们通过代码传递了一些可变状态。有时,我们希望能够传递一些不可变的状态,比如程序的配置数据。在这种情况下我们仍可以使用 State monad ,但这种做法有时候可能会错误地修改了不可变的数据。

让我们暂时忘记 monad 。考虑一个能满足我们要求的函数,它应当具有什么行为?它应当接收一个类型为 e (根据环境而定)的值,该值携带了我们要传入的数据;它应当返回一个其它类型 a 的值作为结果。我们希望整个函数的类型签名为 e -> a

为了将这个类型转化为一个方便的 Monad 实例,我们用一个 newtype 包装它。

-- file: ch15/SupplyInstance.hs
newtype Reader e a = R { runReader :: e -> a }

很容易将它转化为 Monad 实例。

-- file: ch15/SupplyInstance.hs
instance Monad (Reader e) where
        return a = R $ \_ -> a
        m >>= k = R $ \r -> runReader (k (runReader m r)) r

Warning

[译注:在 GHC 7.10 中,你需要同时定义 FunctorApplicative 的实例,其中一种实现如下。]

instance Functor (Reader e) where
__fmap f m = R $ \r -> f (runReader m r)

instance Applicative (Reader e) where
__pure = R . const
__f <*> x = R $ \r -> runReader f r (runReader x r)

我们可以将类型 e 表示的值理解为计算某个表达式所处的环境。因为 return 动作应当在任何情况下都具有相同的效果,所以这段代码不必考虑 return 所处的环境。

为了让环境 ———— 也即是代码中的变量 r ———— 能够在当前和接下来的计算中使用,我们使用了比较复杂的 (>>=) 定义。

在 monad 中执行的一段代码可以通过 ask 来获取环境中包含的值。

-- file: ch15/SupplyInstance.hs
ask :: Reader e e
ask = R id

在给定的一连串动作中,每次调用 ask 都将返回相同的值,因为存储在环境中的值不会改变。我们可以在 ghci 中方便地测试代码。

ghci> runReader (ask >>= \x -> return (x * 3)) 2
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package random-1.0.0.0 ... linking ... done.
6

Reader monad 包含在标准 mtl 库中,它通常与 GHC 捆绑在一起。你可以在 Control.Monad.Reader 模块中找到它。你可能不明白设计这个 monad 的动机是什么,实际上它通常在复杂的代码中更有用。我们时常需要访问程序深处的一段配置信息,若将这些信息作为正常参数传递,我们需要对代码做一次极为痛苦的重构。如果将这些信息隐藏在 monad 的管道中,那么不关心配置信息的中间函数就不需要看到它们。

使用 Reader monad 最明显的动机将在 monad-transformers 中介绍,我们将讨论如何把几个 monad 合并为一个新的 monad 。那时候,我们将学会如何利用 Reader monad 去更好地控制状态,以便我们的代码可以通过 State monad 修改一些值,而其他值则保持不变。

返回自动导出

既然我们了解了 Reader monad ,下面让我们用它来创建一个 MonadSupply 类型类的实例。为了保持例子的简洁性,这里我们将违反 MonadSupply 的精神:我们的 next 动作将始终返回相同的值。

Reader 类型变成一个 MonadSupply 类实例是一个糟糕的想法,因为任意 Reader 都可以表现为一个 MonadSupply 。所以这样的做法通常没有任何意义。

事实上,我们在 Reader 的基础上创建一个 newtype 。这个 newtype 隐藏了内部使用 Reader 的事实。我们必须让类型成为我们关心的类型类的一个实例。通过激活 GeneralizedNewtypeDeriving 扩展功能,GHC 会帮助我们完成大部分困难的工作。

-- file: ch15/SupplyInstance.hs
newtype MySupply e a = MySupply { runMySupply :: Reader e a }
        deriving (Monad)

instance MonadSupply e (MySupply e) where
        next = MySupply $ do
                         v <- ask
                         return (Just v)

        -- more concise:
        -- next = MySupply (Just `liftM` ask)

注意,类型必须是 MonadSupply e 的实例,而不是 MonadSupply 。如果我们省略了类型变量,编译器将会报错。[译注:在类型声明中指定类型依赖的方式是在类名和实例名中使用相同的参数名。]

要尝试我们的 MySupply 类型,首先创建一个能处理任何 MonadSupply 实例的简单函数。

-- file: ch15/SupplyInstance.hs
xy :: (Num s, MonadSupply s m) => m s
xy = do
  Just x <- next
  Just y <- next
  return (x * y)

正如我们期望的那样,如果我们将 Supply monad 和 randomsIO 函数与上面这个函数结合,那么每次都会得到一个不同的结果。

ghci> (fst . runSupply xy) `fmap` randomsIO
-15697064270863081825448476392841917578
ghci> (fst . runSupply xy) `fmap` randomsIO
17182983444616834494257398042360119726

因为 MySupply monad 由两层 newtype 包装,为了使其更易使用,我们可以为它编写一个自定义的执行函数:

-- file: ch15/SupplyInstance.hs
runMS :: MySupply i a -> i -> a
runMS = runReader . runMySupply

当我们通过这个执行函数来应用 xy 动作时,每次都会得到相同的结果。虽然代码没有改动,但因为我们在不同 MonadSupply 的实现下执行,所以它的行为改变了。

ghci> runMS xy 2
4
ghci> runMS xy 2
4

就像我们的 MonadSupply 类型类和 Supply monad,几乎所有常见的 Haskell monad 都建立在接口与实现分离的情况下。举个例子,我们介绍过两个 “属于” State monad 的函数 getput ,它们事实上是 MonadState 类型类的方法; State 类型只是这个类的一个实例。

类似的,标准的 Reader monad 是类型类 MonadReader 的实例,它指定了 ask 方法。

我们上面讨论的接口和实现分离不止对于架构清洁有帮助,它重要的实际应用在日后将更加清楚。当我们在 monad-transformers 中开始合并 monad 时,我们会通过使用 GeneralizedNewtypeDeriving 和类型类来节约大量的工作。

隐藏 IO monad

IO monad 的 “祝福”和 “诅咒” 都来源于它的过分强大。假如我们相信小心地使用类型能帮助我们杜绝程序错误,那么 IO monad 将成为错误的一个重要来源。因为 IO monad 对我们做的事情不加以限制,这使我们容易遭受各种意外。

我们如何驯服它的力量?假如我们想保证一段代码可以读取和写入本地文件,但它不能访问网络。这时我们不能使用 IO monad,因为它不会限制我们。

使用 newtype

让我们创建一个新模块,它提供了一组读取和写入文件的功能。

-- file: ch15/HandleIO.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module HandleIO
        (
          HandleIO
        , Handle
        , IOMode(..)
        , runHandleIO
        , openFile
        , hClose
        , hPutStrLn
        ) where

import System.IO (Handle, IOMode(..))
import qualified System.IO

我们要创建有限制 IO 的第一步是使用 newtype 对其进行包装。

-- file: ch15/HandleIO.hs
newtype HandleIO a = HandleIO { runHandleIO :: IO a }
        deriving (Monad)

我们使用已经熟悉的技巧,从模块中导出类型构造函数和 runHandleIO 执行函数,但不导出数据构造器。这可以阻止在 HandleIO monad 中执行的代码获取它所包装的 IO monad。

剩下的工作就是包装我们希望 monad 允许的每个动作。这可以通过用 HandleIO 数据构造函数包装每个 IO 实现。

-- file: ch15/HandleIO.hs
openFile :: FilePath -> IOMode -> HandleIO Handle
openFile path mode = HandleIO (System.IO.openFile path mode)

hClose :: Handle -> HandleIO ()
hClose = HandleIO . System.IO.hClose

hPutStrLn :: Handle -> String -> HandleIO ()
hPutStrLn h s = HandleIO (System.IO.hPutStrLn h s)

我们现在可以使用有限制的 HandleIO monad 来执行 I/O 操作。

-- file: ch15/HandleIO.hs
safeHello :: FilePath -> HandleIO ()
safeHello path = do
  h <- openFile path WriteMode
  hPutStrLn h "hello world"
  hClose h

要执行这个动作,我们使用 runHandleIO

ghci> :load HandleIO
[1 of 1] Compiling HandleIO         ( HandleIO.hs, interpreted )
Ok, modules loaded: HandleIO.
ghci> runHandleIO (safeHello "hello_world_101.txt")
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package filepath-1.1.0.0 ... linking ... done.
Loading package directory-1.0.0.0 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
ghci> :m +System.Directory
ghci> removeFile "hello_world_101.txt"

假如我们试图用一种不被允许的方式排列一个在 HandleIO monad 中执行的动作,类型系统会加以阻止。

ghci> runHandleIO (safeHello "goodbye" >> removeFile "goodbye")

<interactive>:1:36:
        Couldn't match expected type `HandleIO a'
                   against inferred type `IO ()'
        In the second argument of `(>>)', namely `removeFile "goodbye"'
        In the first argument of `runHandleIO', namely
                `(safeHello "goodbye" >> removeFile "goodbye")'
        In the expression:
                runHandleIO (safeHello "goodbye" >> removeFile "goodbye")

针对意外使用情况设计

HandleIO monad 有一个重要的小问题:我们可能偶尔需要一个 “逃生舱门” ,而 HandleIO monad 没有考虑到这种可能性。如果定义一个这样的 monad,我们很有可能会偶尔执行 monad 设计所不允许的 I/O 动作。

设计这个 monad 的目的是让我们更容易在普通情况下编写无错代码,而不是为了杜绝特殊情况发生。让我们给自己留一条出路。

Conotrol.Monad.Trans 模块定义了一个 “标准逃生舱门” : MonadIO 类型类。函数 liftIO 可以将一个 IO 动作嵌入另一个 monad 中。

ghci> :m +Control.Monad.Trans
ghci> :info MonadIO
class (Monad m) => MonadIO m where liftIO :: IO a -> m a
        -- Defined in Control.Monad.Trans
instance MonadIO IO -- Defined in Control.Monad.Trans

要实现这个类型类非常简单,只需要用我们自己的数据构造器将 IO 包装起来就可以了:

-- file: ch15/HandleIO.hs
import Control.Monad.Trans (MonadIO(..))

instance MonadIO HandleIO where
        liftIO = HandleIO

通过审慎地使用 liftIO ,我们可以逃脱束缚,并在必要的时候调用 IO 操作。

-- file: ch15/HandleIO.hs
tidyHello :: FilePath -> HandleIO ()
tidyHello path = do
  safeHello path
  liftIO (removeFile path)

使用类型类

IO 隐藏到另一个 monad 的缺陷是我们仍然绑定到了一个具体的实现。假如要将 HandleIO 和一些其它的 monad 交换,就必须改变每个使用 HandleIO 的动作的类型。

一种替代方式是创建一个类型类。我们想从一个操作文件的 monad 中获取接口,这个类型类指定了这个接口。

-- file: ch15/MonadHandle.hs
{-# LANGUAGE FunctionalDependencies, MultiParamTypeClasses #-}

module MonadHandle (MonadHandle(..)) where

import System.IO (IOMode(..))

class Monad m => MonadHandle h m | m -> h where
        openFile :: FilePath -> IOMode -> m h
        hPutStr :: h -> String -> m ()
        hClose :: h -> m ()
        hGetContents :: h -> m String

        hPutStrLn :: h -> String -> m ()
        hPutStrLn h s = hPutStr h s >> hPutStr h "\n"

现在,我们决定抽象出 monad 的类型和文件句柄的类型。为了满足类型检查器,这里添加了一个函数依赖:对于 MonadHandle 的任何实例,只有一个句柄类型可以使用。当 IO monad 成为这个类的一个实例时,我们使用一个普通的 Handle

-- file: ch15/MonadHandleIO.hs
{-# LANGUAGE FunctionalDependencies, MultiParamTypeClasses #-}

import MonadHandle
import qualified System.IO

import System.IO (IOMode(..))
import Control.Monad.Trans (MonadIO(..), MonadTrans(..))
import System.Directory (removeFile)

import SafeHello

instance MonadHandle System.IO.Handle IO where
        openFile = System.IO.openFile
        hPutStr = System.IO.hPutStr
        hClose = System.IO.hClose
        hGetContents = System.IO.hGetContents
        hPutStrLn = System.IO.hPutStrLn

因为任何 MonadHandle 也必须是一个 Monad ,所以我们可以使用普通的 do 标识符编写修改文件的代码,而不需要关心它最终在哪个 monad 中执行。

-- file: ch15/SafeHello.hs
safeHello :: MonadHandle h m => FilePath -> m ()
safeHello path = do
  h <- openFile path WriteMode
  hPutStrLn h "hello world"
  hClose h

我们已经让 IO 成为这个类型类的一个实例,现在可以在 ghci 中执行这个动作:

ghci> safeHello "hello to my fans in domestic surveillance"
Loading package old-locale-1.0.0.0 ... linking ... done.
Loading package old-time-1.0.0.0 ... linking ... done.
Loading package filepath-1.1.0.0 ... linking ... done.
Loading package directory-1.0.0.0 ... linking ... done.
Loading package mtl-1.1.0.0 ... linking ... done.
ghci> removeFile "hello to my fans in domestic surveillance"

使用类型类的好处在于,我们可以不需要接触过多代码并互换一个底层的 monad ,因为大多数代码不知道或不关心具体底层 monad 的实现。例如,可以用一个 monad 来替换 IO ,它会在文件写入时压缩文件。

通过类型类定义一个 monad 的接口有另一个好处:它允许其他人在 newtype 包装器中隐藏我们的实现,并自动派生他们想要暴露的类型类的实例。

隔离和测试

事实上,因为 safeHello 函数没有使用 IO 类型,我们甚至可以使用不能执行 I/O 的 monad。通过这种方法,我们可以在纯粹且受控的环境中对那些在平常情况下会出现副作用的代码进行测试。

为此,我们将创建一个 monad,这个 monad 不会执行任何 I/O 操作,但它会记录每个与文件相关联的事件以供后续处理。

-- file: ch15/WriterIO.hs
data Event = Open FilePath IOMode
                   | Put String String
                   | Close String
                   | GetContents String
                         deriving (Show)

虽然在 使用新的Monad 中已经开发了一个 Logger 类型,但这里我们将使用标准的、更广泛的 Writer monad。和其它 mtl monad 类似, Writer 提供的 API 将在一个类型类中被定义,也就是接下来要展示的 MonadWriterMonadWriter 最有用的方法是 tell ,这个方法记录了一个值。

ghci> :m +Control.Monad.Writer
ghci> :type tell
tell :: (MonadWriter w m) => w -> m ()

虽然 tell 方法可以记录任意类型的 Monoid ,但是因为列表的类型是 Monoid ,所以 tell 方法将记录一个由 Event 构成的列表。

尽管我们可以让 Writer [Event] 成为 MonadHandle 的一个实例,但一个更廉价也更安全的做法是编写一个特殊的 monad ,并且要做到这一点也比较容易。

-- file: ch15/WriterIO.hs
newtype WriterIO a = W { runW :: Writer [Event] a }
        deriving (Monad, MonadWriter [Event])

执行函数首先会移除我们之前添加的 newtype 包装器,然后再调用普通 Writer monad 的执行函数。

-- file: ch15/WriterIO.hs
runWriterIO :: WriterIO a -> (a, [Event])
runWriterIO = runWriter . runW

当我们在 ghci 中执行这段代码的时候,它将反馈给我们一份日志,这份日志记录了函数对文件所做的行为。

ghci> :load WriterIO
[1 of 3] Compiling MonadHandle      ( MonadHandle.hs, interpreted )
[2 of 3] Compiling SafeHello        ( SafeHello.hs, interpreted )
[3 of 3] Compiling WriterIO         ( WriterIO.hs, interpreted )
Ok, modules loaded: SafeHello, MonadHandle, WriterIO.
ghci> runWriterIO (safeHello "foo")
((),[Open "foo" WriteMode,Put "foo" "hello world",Put "foo" "\n",Close "foo"])

Writer monad 和 列表

每当我们使用 tell 方法时, Writer monad 都会调用 monoid 的 mappend 函数。因为列表的 mappend 动作是 (++) ,而不断重复附加操作是非常浪费资源的,所以在实际使用 Writer 时,列表并不是一个很好的选择。在上面的实例中,我们纯粹是为了追求简单才使用了列表。

在生产代码中,如果你想使用 Writer monad ,并且需要类似列表的行为,那么你应当选择那些在执行附加操作时性能更优的类型。在 把函数当成数据来用 中介绍过的差异列表就是一个这样的类型。你不需要实现自己的差异列表:Haskell 包数据库 Hackage 提供了一个调试好的库,你可以直接下载使用。除此之外,你可以使用 Data.Sequence 模块中的 Seq 类型,我们在 通用序列 中介绍过这个类型。

任意 I/O 访问

在使用类型类方法限制 IO 的同时,我们可能还会希望继续保留执行任意 I/O 操作的能力。为此,我们可以尝试将 MonadIO 作为约束添加到类型类里面。

-- file: ch15/MonadHandleIO.hs
class (MonadHandle h m, MonadIO m) => MonadHandleIO h m | m -> h

instance MonadHandleIO System.IO.Handle IO

tidierHello :: (MonadHandleIO h m) => FilePath -> m ()
tidierHello path = do
  safeHello path
  liftIO (removeFile path)

但是这种方法会导致一个问题:添加 MonadIO 约束将使得我们无法再判断一个测试是否会带有副作用,我们也因此失去了在纯粹的环境中测试代码的能力。另一种方法是调整这个约束的作用域,使它只影响那些真正需要执行 I/O 操作的函数,而非所有函数。

-- file: ch15/MonadHandleIO.hs
tidyHello :: (MonadIO m, MonadHandle h m) => FilePath -> m ()
tidyHello path = do
  safeHello path
  liftIO (removeFile path)

我们可以对不受 MonadIO 约束的函数使用纯属性测试,对其余的函数使用传统的单元测试。

遗憾的是,这种做法只不过是将一个问题换成了另一个问题:只受 MonadHandle 约束的代码将无法调用同时受 MonadIOMonadHandle 约束的代码。如果我们在 MonadHandle 约束的代码内部发现了这个问题,那么我们就必须在引发这个问题的所有代码路径上都加上 MonadIO 约束。

允许执行任意 I/O 操作是有风险的,而且这么做对我们开发、测试代码的流程都会有巨大的影响。相比放宽对代码行为的限制,我们通常会追求更清晰的代码逻辑和更容易的测试环境。

练习

  1. 使用 Quick Check,为 MonadHandle monad 中的一个动作编写一个测试,观察它是否尝试向一个未打开的文件句柄写入。在 safeHello 下尝试。
  2. 编写一个试图向已关闭句柄写入的动作。你的测试捕获到这个问题了吗?
  3. 在表单编码的字符串中,相同的键可能出现数次,每个键具有或不具有对应的值,例如 key&key=1&key=2 。你会用什么类型来表示这类字符串中和键相关联的值?编写一个正确捕获所有信息的解析器。