Haskell/Monad transformers

我們已經學習了Monad能夠如何簡便對於 IO, Maybe, 列表, 和 State 的處理. 每一個Monad都是如此的有用且用途廣泛, 我們自然會尋求將幾個Monad的特性組合起來的方法. 比如, 也許我們可以定義一個既能處理I/O操作, 又能使用 Maybe 提供的異常處理的函數. 雖然這可以用形如 IO (Maybe a) 的函數實現, 但是在這種方式下我們將被迫在 IO 的 do 代碼塊中手工模式匹配提取值, 而避免寫出這種繁瑣而無謂的代碼卻恰恰是 Maybe Monad存在的原因.

於是我們發明了 monad transformers: 它們能夠將幾種Monad的特性融合起來, 同時不失去使用Monad的靈活性.

密碼驗證 編輯

我們先來看一看這個幾乎每一個IT從業人員都會遇到的實際問題: 讓用戶設置足夠強的密碼. 一種方案是: 強迫用戶輸入大於一定長度, 且滿足各種惱人要求的密碼 (例如包含大寫字母, 數字, 字符, 等等).

這是一個用於從用戶處獲取密碼的Haskell函數:

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

-- 我们可以要求密码满足任何我们想要的条件
isValid :: String -> Bool
isValid s = length s >= 8
            && any isAlpha s
            && any isNumber s
            && any isPunctuation s

首先, getPassphrase 是一個 IO Monad, 因為它需要從用戶處獲得輸入. 我們還使用了 Maybe, 因為若密碼沒有通過 isValid 的建議, 我們決定返回 Nothing. 需要注意的是, 我們在這裡並沒有使用到 Maybe 作為Monad的特性: do 代碼塊的類型是 IO monad, 我們只是恰巧 return 了一個 Maybe 類型的值罷了.

Monad transformers 不僅僅使 getPassphrase 的實現變簡單了, 而且還能夠簡化幾乎所有運用了多個monad的代碼. 這是不使用Monad transformers的程序:

askPassphrase :: IO ()
askPassphrase = do putStrLn "输入新密码:"
                   maybe_value <- getPassphrase
                   if isJust maybe_value
                     then do putStrLn "储存中..." -- 假装存在数据库操作
                     else putStrLn "密码无效"

這段代碼單獨使用了一行來獲得 maybe_value, 然後又手工對它進行驗證.

如果使用 monad transformers, 我們將可以把獲得輸入和驗證這兩個步驟合二為一 — 我們將不再需要模式匹配或者等價的 isJust. 在我們的簡單例子中, 或許 monad transformers 只作出了微小的改進, 但是當問題規模進一步擴大時, 它們將發揮巨大的作用.

一個簡單的 monad transformer: MaybeT 編輯

為了簡化 getPassphrase 以及所有使用到它的函數, 我們定義一個賦予 IO monad 一些 Maybe monad 特性的 monad transformer; 我們將其命名為 MaybeT. 一般來說, monad transformers 的名字都會以 "T" 結尾, 而之前的部分 (例如, 在本例中是"Maybe") 表示它所提供的特性.

MaybeT 是一個包裝 (wrap) 了 m (Maybe a) 的類型, 其中 m 可以是任何Monad (在本例中即為 IO):

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

這裡的 datatype 聲明定義了 MaybeT, 一個被 m 參數化的類型構造子 (type constructor), 以及一個值構造函數 (value constructor), 同樣被命名為 MaybeT, 以及一個作用為簡便的內部值訪問的函數 runMaybeT.

Monad transformers 的關鍵在於 他們本身也是monads; 因此我們需要寫出 MaybeT mMonad 類型類實例:

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

首先, 我們先用 Just 將值裝入最內層的 Maybe 中, 然後用 return 將前述 Maybe 裝入 m monad里, 最後再用 MaybeT 將整個值包裝起來.


我們也可以這樣實現 (雖然是不是增加了可讀性仍然有待商榷) return = MaybeT . return . return.

如同在所有monad中一樣, (>>=) 也是 transformer 的核心.

-- 特化为 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

我們從 do 代碼塊的第一行開始解釋:

  • 首先, runMaybeTx 中取出 m (Maybe a). 我們由此可知整個 do 代碼塊的類型為 monad m.
  • 第一行稍後, <- 從上述值中取出了 Maybe a.
  • case 語句對 maybe_value 進行檢測:
    • 若其為 Nothing, 我們將 Nothing 返回至 m 當中;
    • 若其為 Just, 我們將函數 f 應用到其中的 value 上. 因為 f 的返回值類型為 MaybeT m b, 我們需要再次使用 runMaybeT 來將其返回值提取回 m monad 中.
  • 最後, do 代碼塊的類型為 m (Maybe b); 因此我們用 MaybeT 值構造函數將它包裝進 MaybeT 中.

這咋看來獲取有些複雜; 但是若剔除大量的包裝和解包裝, 我們的代碼和 Maybe monad 的實現其實很像:

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

我們在 do 代碼塊中仍然能夠使用 runMaybeT, 為何要在前者上應用 MaybeT 值構造函數呢? 這是因為我們的 do 代碼塊必須是 m monad, 而不是 MaybeT m monad, 因為後者此時還沒有定義 (>>=). (回想一下 do 語法糖是如何工作的)

註解

return 的實現中層層相疊的函數也許可以為我們提供一個(或許)有助於理解的比喻: 將複合的 monad 想象為一個 三明治; 雖然這個比喻似乎在暗示實際上有三個 monad 在起作用, 但是實際上只有兩個: 內層的以及"融合了的" monad. 置於基層的 monad (本例中即Maybe) 中並沒有用到 (>>=) 或者 return, 它只是作為 transformer 實現的一部分罷了. 如果你覺得這個比喻很有道理, 將 transformer 和 底層 monad 想象成三明治的兩片包裹着內層 monad 的麵包. [1]

理論上, 這就是我們所需的全部了; 但是, 為 MaybeT 實現幾個其他類型類的 instance 又何妨呢?

instance Monad m => MonadPlus (MaybeT m) where
    mzero     = MaybeT $ return Nothing
    mplus x y = MaybeT $ do maybe_value <- runMaybeT x
                            case maybe_value of
                                 Nothing    -> runMaybeT y
                                 Just _     -> return maybe_value

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

MonadTrans monad 實現了 lift 函數, 由此我們可以在 MaybeT m monad 的 do 代碼塊中使用m monad 的值. 至於 MonadPlus, 因為 Maybe 有着這個類型類的 instance, MaybeT 也應該有着對應的 instance.

應用在密碼驗證的例子中 編輯

現在, 前例的密碼管理可以被改寫為:

getValidPassphrase :: MaybeT IO String
getValidPassphrase = do s <- lift getLine
                        guard (isValid s) -- MonadPlus 类型类使我们能够使用 guard.
                        return s

askPassphrase :: MaybeT IO ()
askPassphrase = do lift $ putStrLn "输入新密码:"
                   value <- getValidPassphrase
                   lift $ putStrLn "储存中..."

這段代碼簡潔多了, 特別是 askPassphrase 函數. 最重要的是, 有着 (>>=) 為我們代勞, 我們不再需要手動檢查結果是 NothingJust 中的哪一個了.

我們使用了 lift 函數以在 MaybeT IO monad 中使用 getLineputStrLn; 因為 MaybeT IO 有着 MonadPlus 類型類的 instance, guard 可以為我們檢查代碼的合法性. 在密碼不合法時其將返回 mzero (即 IO Nothing).

碰巧, 有了 MonadPlus, 我們可以優雅地不停要求用戶輸入一個合法的密碼:

askPassword :: MaybeT IO ()
askPassword = do lift $ putStrLn "输入新密码:"
                 value <- msum $ repeat getValidPassphrase
                 lift $ putStrLn "储存中..."

泛濫的 transformer 編輯

transformers 包提供了許多包含常見 monad 的 transformer 版本的模塊 (例如 Template:Haskell lib 模塊提供了 MaybeT). 它們和對應的非 transformer 版本 monad 是兼容的; 換句話說, 除去一些和另一個 monad 交互所用到的包裝和解包裝 , 它們的實現基本上是一致的. 從今往後, 我們稱 transformer monad 所基於的非 transformer 版本的 monad 為 基層 monad (例如 MaybeT 中的 Maybe); 以及稱應用在 transformer 上的 monad 為 內層 monad (例如 MaybeT IO 中的 IO).

我們隨便舉一個例子, ReaderT Env IO String. 這是一個能夠從某個外部環境讀取 Env 類型的值 (並且和 Reader, 也就是基 monad, 使用方法相同) 並且能夠進行一些I/O, 最後返回一個 String 類型的值的 monad. 由於這個 transformer 的 (>>=)return 語義和基 monad 相同, 一個 ReaderT Env IO String 類型的 do 語句塊從外部看來和另一個 Reader 類型的 do 語句塊並無二致, 除了我們需要用 lift 來使用 IO monad.

類型戲法 編輯

我們已經了解到, MaybeT 的類型構造子實際上是一個對內層 monad 中的 Maybe 值的包裝. 因此, 用於取出內部值的函數 runMaybeT 返回給我們一個類型為 m (Maybe a) 的值 - 也就是一個由內層 monad 包裝着的基 monad 值. 類似的, 對於分別基於列表和 Either 構建的 ListTExceptT transformer:

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

and

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

然而並不是所有的 transformer 都和它們的基 monad 有着類似的關係. 不同於上面兩例所給出的基 monad, Writer, Reader, State, 以及 Cont monad 既沒有多個值構造子, 也沒有接收多個參數的值構造函數. 因此, 它們提供了形如 run... 的函數以作為解包裝函數, 而它們的 transformer 則提供形如 run...T 的函數. 下表給出了這些 monad 的 run...run...T 函數的類型, 而這些類型事實上就是包裝在基 monad 和對應的 transformer 中的那些. [2]

基 Monad Transformer 原始類型
(被基 monad 包裝的類型)
複合類型
(被 transformer 包裝的類型)
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

我們可以注意到, 基 monad 並沒有出現在複合的類型中. 這些 monad 並沒有一個像 Maybe 或列表那樣的有意義的構造函數, 因此我們選擇不在 transformer monad 中保留基 monad. 值得注意的是, 在後三個例子中我們包裝的值是函數. 拿 StateT 作例子, 將原有的表示狀態轉換的 s -> (a, s) 函數變為 s -> m (a, s) 的形式; 然而我們只將函數的返回值 (而不是整個函數) 包裝進內層 monad 中. ReaderT 也與此差不多. ContT 卻與它們不同: 由於 Cont (表示延續的 monad) 的性質, 被包裝的函數和作這個函數參數的函數返回值必須相同, 因此 transformer 將兩個值都包裝入內層 monad 中. 遺憾的是, 並沒有一種能將普通的 monad 轉換成 transformer 版本的萬靈藥; 每一種 transformer 的實現都和基 monad 的行為有關.

Lifting 編輯

我們將仔細研究 lift 函數: 它是應用 monad transformers 的關鍵. 首先, 我們需要對它的名字 "lift" 做一些澄清. 在理解_Monad中, 我們已經學習過一個名字類似的函數 liftM. 我們了解到, 它實際上是 monad 版本的 fmap:

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

liftM 將一個類型為 (a -> b) 的函數應用到一個 m monad 內的值上. 我們也可以將它視為只有一個參數:

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

liftM 將一個普通的函數轉換為在 m monad 上運作的函數. 我們用"提升(lifting)"表示將一樣東西帶至另一樣東西中 — 在前例中, 我們將一個函數提升到了 m monad 中.

有了 liftM, 我們不需要用 do 代碼塊或類似的技巧就能夠把尋常的函數應用在 monad 上了:

do notation liftM
do x <- monadicValue
   return (f x)
liftM f monadicValue

類似的, lift 函數在 transformer 上起到了一個相似的作用. 它將內層 monad 中的計算帶至複合的 monad (即 transformer monad) 中. 這使得我們能夠輕易地在複合 monad 中插入一個內層 monad 的運算.

liftMonadTrans 類型類的唯一一個函數, 參見 Template:Haskell lib. 所有的 transformer 都有 MonadTrans 的 instance, 因此我們能夠在任何一個 transformer 上使用 lift.

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

lift 存在一個 IO 的變種, 名叫 liftIO; 這是 MonadIO 類型類的唯一一個函數, 參見 Template:Haskell lib.

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

當多個 transformer 被組合在一起時, liftIO 能夠帶來一些便利. 在這種情況下, IO 永遠是最內層的 monad (因為不存在 IOT transformer), 因此一般來說我們需要多次使用 lift 以將 IO 中的值從底層提升至複合 monad 中. 定義了 liftIO instance 的類型被設計成能夠使用 liftIOIO 從任意深的內層一次性提升到 transformer monad 中.

實現 lift 編輯

lift 並不是很複雜. 以 MaybeT transformer 為例:

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

我們從接收到一個內層 monad 中的值的參數開始. 我們使用 liftM (或者 fmap, 因為所有Monad都首先是Functor) 和 Just 值構造函數來將基 monad 插入內層 monad 中, 以從 m a 轉化為 m (Maybe a)). 最後, 我們使用 MaybeT 值構造函數將三明治包裹起來. 值得注意的是, 此例中 liftM 工作於內層 monad 中, 如同我們之前看到的 MaybeT(>>=) 實現中的 do 代碼塊一樣.

練習
  1. 為什麼 lift 必須為每一個 monad transformer 單獨定義, 但 liftM 卻可以只實現一次呢?
  2. Identity 是一個定義於 Data.Functor.Identity 中實際意義不大的 functor:
    newtype Identity a = Identity { runIdentity :: a }
    它有着如下的 Monad instance:
    instance Monad Identity where
        return a = Identity a
        m >>= k  = k (runIdentity m)
    
    實現一個 monad transformer IdentityT, 這個 transformer 和 Identity 類似, 但是將值包裹在 m a 而不是 a 類型的值中. 請你至少寫出它的 MonadMonadTrans instances.

實現 transformers 編輯

State transformer 編輯

作為一個額外的例子, 我們將試着實現 StateT. 請確認自己了解 State 再繼續. [3]

正如同 State monad 有着 newtype State s a = State { runState :: (s -> (a,s)) } 的定義一樣, StateT transformer 的定義為:

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

StateT s m 有着如下的 Monad instance, 旁邊用基 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          -- 取得新的值和状态
    runStateT (f v) s'     -- 将它们传递给 f

我們的 return 實現使用了內層 monad 的 return 函數. (>>=) 則使用一個 do 代碼塊來在內層 monad 中進行計算.

註解

現在我們能夠解釋, 為什麼在 State monad 中存在手工定義的 state 卻沒有與類型一同定義的 State 值構造函數了. 在 transformersmtl 包中, State s 被定義為 StateT s Identity 的類型別名, 其中 Identity 是在本章練習中出現的沒有特別效果的 monad. 這個定義和我們迄今為止所見的使用 newtype 的定義是等價的.

為了將 StateT s m monad 同 State monad 一般使用, 我們自然需要核心的 getput 函數. 這裡我們將使用 mtl 的代碼風格. mtl 不僅僅提供了 monad transformers 的定義, 還提供了定義了常見 monad 的關鍵操作的類型類. 例如, Template:Haskell lib 包所提供的 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 instance 的類型 m, sStateT s m 共同組成了一個 MonadState 的 instance". sm 分別對應了 state monad 和內層 monad. 類型參數 s 是 instance 聲明的一個獨立部分, 因此函數能夠訪問到 s: 舉個例子, put 的類型為 s -> StateT s m ().

存在為其他 transformer 包裝着的 state monad 所定義的 MonadState instance, 例如 MonadState s m => MonadState s (MaybeT m). 這些 instance 使得我們不再需要寫出 lift 來使用 get and put, 因為複合 monad 的 MonadState instance 為我們代勞了.

我們還可以為複合 monad 實現內層 monad 所擁有的一些類型類 instance. 例如, 所有包裝了 StateT (其擁有 MonadPlus instance) 的複合 monad 也同樣能夠擁有 MonadPlus 的 instance:

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

mzeromplus 的實現符合我們的直覺; 它們將實際的操作下推給內層 monad 來完成.

最後, monad transformer 必須有 MonadTrans 的 instance, 不然我們無法使用 lift:

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

lift 函數返回一個包裝在 StateT 中的函數, 其接受一個表示狀態的值 s 作為參數, 返回一個函數, 這個函數接受一個內層 monad 中的值, 並將它通過 (>>=) 傳遞給一個將其和狀態打包成一個在內層 monad 中的二元組的函數. 舉個例子, 當我們在 StateT transformer 中使用列表時, 一個返回列表的函數 (即一個在列表 monad 中的計算) 在被提升為 StateT s []後, 將變成一個返回 StateT (s -> [(a,s)]) 的函數. 換句話說, 這個函數用初始狀態產生了多個 (值, 新狀態) 的二元組. 我們將 StateT 中的計算 "fork" 了, 為被提升的函數返回的列表中的每一個值創建了一個計算分支. 當然了, 將 StateT 和不同的 monad 組合將產生不同效果的 lift 函數.

練習
  1. 使用 getput 來實現 state :: MonadState s m => (s -> (a, s)) -> m a.
  2. MaybeT (State s)StateT s Maybe 等價嗎? (提示: 比較兩個 run...T 函數的返回值.

致謝 編輯

本章摘取了 All About Monads, 已取得作者 Jeff Newbern 的授權.

Template:Haskell/NotesSection



Monad transformers
習題解答
Monads

理解 Monad  >> 高級 Monad  >> Monad 進階  >> MonadPlus  >> Monadic parser combinators  >> Monad transformers  >> Monad 實務


Haskell

Haskell基礎 >> 初級Haskell >> Haskell進階 >> Monads
高級Haskell >> 類型的樂趣 >> 理論提升 >> Haskell性能


庫參考 >> 普通實務 >> 特殊任務

  1. 譯者並沒有看懂這個比喻, 請批判性地閱讀. 看得懂的人希望能夠幫忙確認或修改.
  2. 嚴格意義上, 這個解釋只在大於 2.0.0.0 版本的 mtl 包中成立.
  3. 譯註: 此處作者引用了 理解_Monad 中的內容, 然而譯時此章節並沒有翻譯完成.