跳轉到內容

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包中嚴格適用。
華夏公益教科書