第十八章: Monad變換器

2018-02-24 15:49 更新

第十八章: Monad變換器

動機: 避免樣板代碼

Monad提供了一種強大途徑以構建帶效果的計算。雖然各個標準monad皆專一于其特定的任務,但在實際代碼中,我們常常想同時使用多種效果。

比如,回憶在第十章中開發(fā)的 Parse 類型。在介紹monad之時,我們提到這個類型其實是喬裝過的 State monad。事實上我們的monad比標準的 State monad 更加復雜:它同時也使用了 Either 類型來表達解析過程中可能的失敗。在這個例子中,我們想在解析失敗的時候就立刻停止這個過程,而不是以錯誤的狀態(tài)繼續(xù)執(zhí)行解析。這個monad同時包含了帶狀態(tài)計算的效果和提早退出計算的效果。

普通的 State monad不允許我們提早退出,因為其只負責狀態(tài)的攜帶。其使用的是 fail 函數的默認實現:直接調用 error 拋出異常 - 這一異常無法在純函數式的代碼中捕獲。因此,盡管 State monad似乎允許錯誤,但是這一能力并沒有什么用。(再次強調:請盡量避免使用 fail 函數?。?/p>

理想情況下,我們希望能使用標準的 State monad,并為其加上實用的錯誤處理能力以代替手動地大量定制各種monad。雖然在 mtl 庫中的標準monad不可合并使用,但使用庫中提供了一系列的 monad變換器 可以達到相同的效果。

Monad變換器和常規(guī)的monad很類似,但它們并不是獨立的實體。相反,monad變換器通過修改其以為基礎的monad的行為來工作。 大部分 mtl 庫中的monad都有對應的變換器。習慣上變換器以其等價的monad名為基礎,加以 T 結尾。 例如,與 State 等價的變換器版本稱作 StateT ; 它修改下層monad以增加可變狀態(tài)。此外,若將 WriterT monad變換器疊加于其他(或許不支持數據輸出的)monad之上,在被monad修改后的的monad中,輸出數據將成為可能。

[注:mtl 意為monad變換器函數庫(Monad Transformer Library)]

[譯注:Monad變換器需要依附在一已有monad上來構成新的monad,在接下來的行文中將使用“下層monad”來稱呼monad變換器所依附的那個monad]

簡單的Monad變換器實例

在介紹monad變換器之前,先看看以下函數,其中使用的都是之前接觸過的技術。這個函數遞歸地訪問目錄樹,并返回一個列表,列表中包含樹的每層的實體個數:

-- file: ch18/CountEntries.hs
module CountEntries
  ( listDirectory
  , countEntriesTrad
  ) where

import System.Directory (doesDirectoryExist, getDirectoryContents)
import System.FilePath ((</>))
import Control.Monad (forM, liftM)

listDirectory :: FilePath -> IO [String]
listDirectory = liftM (filter notDots) . getDirectoryContents
  where notDots p = p /= "." && p /= ".."

countEntriesTrad :: FilePath -> IO [(FilePath, Int)]
countEntriesTrad path = do
    contents <- listDirectory path
    rest <- forM contents $ \name -> do
        let newName = path </> name
        isDir <- doesDirectoryExist newName
        if isDir
          then countEntriesTrad newName
          else return []
    return $ (path, length contents) : concat rest

現在看看如何使用 Writer monad 實現相同的目標。由于這個monad允許隨時記下數值,所以并不需要我們顯示地去構建結果。

為了遍歷目錄,這個函數必須在 IO monad中執(zhí)行,因此我們無法直接使用 Writer monad。但我們可以用 WriterT 將記錄信息的能力賦予 IO 。一種簡單的理解方法是首先理解涉及的類型。

通常 Writer monad有兩個類型參數,因此寫作 Writerwa 更為恰當。其中參數 w 用以指明我們想要記錄的數值的類型。而另一類型參數 a 是monad類型類所要求的。因此 Writer[(FilePath,Int)]a 是個記錄一列目錄名和目錄大小的writer monad。

WriterT 變換器有著類似的結構。但其增加了另外一個類型參數 m :這便是下層monad,也是我們想為其增加功能的monad。 WriterT 的完整類型簽名是 Writerwma。

由于所需的目錄遍歷操作需要訪問 IO monad,因此我們將writer功能累加在 IO monad之上。通過將monad變換器與原有monad結合,我們得到了類型簽名: WriterT[(FilePath,Int)]IOa 這個monad變換器和monad的組合自身也是一個monad:

-- file: ch18/CountEntriesT.hs
module CountEntriesT
  ( listDirectory
  , countEntries
  ) where

import CountEntries (listDirectory)
import System.Directory (doesDirectoryExist)
import System.FilePath ((</>))
import Control.Monad (forM_, when)
import Control.Monad.Trans (liftIO)
import Control.Monad.Writer (WriterT, tell)

countEntries :: FilePath -> WriterT [(FilePath, Int)] IO ()
countEntries path = do
    contents <- liftIO . listDirectory $ path
    tell [(path, length contents)]
    forM_ contents $ \name -> do
        let newName = path </> name
        isDir <- liftIO . doesDirectoryExist $ newName
        when isDir $ countEntries newName

代碼與其先前的版本區(qū)別不大,需要時 liftIO 可以將 IO monad暴露出來;同時, tell 可以用以記下對目錄的訪問。

為了執(zhí)行這一代碼,需要選擇一個 WriterT 的執(zhí)行函數:

ghci> :type runWriterT
runWriterT :: WriterT w m a -> m (a, w)
ghci> :type execWriterT
execWriterT :: Monad m => WriterT w m a -> m w

這些函數都可以用以執(zhí)行動作,移除 WriterT 的包裝,并將結果交給其下層monad。其中 runWriterT 函數同時返回動作結果以及在執(zhí)行過程獲得的記錄。而 execWriterT 丟棄動作的結果,只將記錄返回。

因為沒有 IOT 這樣的monad變換器,所以此處我們在 IO 之上使用 WriterT 。一旦要用 IO monad和其他的一個或多個monad變換器結合, IO 一定在monad棧的最底下。

[譯注:“monad?!庇蒻onad和一個或多個monad變換器疊加而成,形成一個棧的結構。若在monad棧中需要 IO monad,由于沒有對應的monad變換器( IOT ),所以 IO monad只能位于整個monad棧的最底下。此外, IO 是一個很特殊的monad,它的 IOT 版本是無法實現的。]

Monad和Monad變換器中的模式

在 mtl 庫中的大部分monad與monad變換器遵從一些關于命名和類型類的模式。

為說明這些規(guī)則,我們將注意力聚焦在一個簡單的monad上: reader monad。 reader monad的具體API位于 MonadReader 中。大部分 mtl 中的monad都有一個名稱相對的類型類。例如 MonadWriter 定義了writer monad的API,以此類推。

-- file: ch18/Reader.hs
{-# LANGUAGE FunctionalDependencies #-}
class Monad m => MonadReader r m | m -> r where
    ask :: m r
    local :: (r -> r) -> m a -> m a

其中類型變量 r 表示reader monad所附帶的不變狀態(tài), Readerr monad是個 MonadReader 的實例,同時 ReaderTrm monad變換器也是一個。這個模式同樣也在其他的 mtl monad中重復著: 通常有個具體的monad,和其對應的monad變換器,而它們都是相應命令的類型類的實例。這個類型類定義了功能相同的monad的API。

回到我們reader monad的例子中,我們之前尚未討論過 local 函數。通過一個類型為 r->r 的函數,它可臨時修改當前的環(huán)境,并在這一臨時環(huán)境中執(zhí)行其動作。舉個具體的例子:

-- file: ch18/LocalReader.hs
import Control.Monad.Reader

myName step = do
    name <- ask
    return (step ++ ", I am " ++ name)

localExample :: Reader String (String, String, String)
localExample = do
    a <- myName "First"
    b <- local (++"dy") (myName "Second")
    c <- myName "Third"
    return (a,b,c)

若在 ghci 中執(zhí)行 localExample ,可以觀察到對環(huán)境修改的效果被限制在了一個地方:

ghci> runReader localExample "Fred"
Loading package mtl-1.1.0.1 ... linking ... done.
("First, I am Fred","Second, I am Freddy","Third, I am Fred")

當下層monad m 是一個 MonadIO 的實例時, mtl 提供了關于 ReaderTrm 和其他類型類的實例,這里是其中的一些:

-- file: ch18/Reader.hs
instance (Monad m) => Functor (ReaderT r m) where
    ...

instance (MonadIO m) => MonadIO (ReaderT r m) where
    ...

instance (MonadPlus m) => MonadPlus (ReaderT r m) where
    ...

再次說明:為方便使用,大部分的 mtl monad變換器都定義了諸如此類的實例。

疊加多個Monad變換器

之前提到過,在常規(guī)monad上疊加monad變換器可得到另一個monad。由于混合的結果也是個monad,我們可以憑此為基礎再疊加上一層monad變換器。事實上,這么做十分常見。但在什么情況下才需要創(chuàng)建這樣的monad呢?

  • 若代碼想和外界打交道,便需要 IO 作為這個monad棧的基礎。否則普通的monad便可以滿足需求。
  • 加上一層 ReaderT ,以添加訪問只讀配置信息的能力。
  • 疊加上 StateT ,就可以添加可修改的全局狀態(tài)。
  • 若想得到記錄事件的能力,可以添加一層 WriterT 。

這個做法的強大之處在于:我們可以指定所需的計算效果,以量身定制monad棧。

舉個多重疊加的moand變換器的例子,這里是之前開發(fā)的 countEntries 函數。我們想限制其遞歸的深度,并記錄下它在執(zhí)行過程中所到達的最大深度:

-- file: ch18/UglyStack.hs
import System.Directory
import System.FilePath
import System.Monad.Reader
import System.Monad.State

data AppConfig = AppConfig
  { cfgMaxDepth :: Int
  } deriving (Show)

data AppState = AppState
  { stDeepestReached :: Int
  } deriving (Show)

此處使用 ReaderT 來記錄配置數據,數據的內容表示最大允許的遞歸深度。同時也使用了 StateT 來記錄在實際遍歷過程中所達到的最大深度。

-- file: ch18/UglyStack.hs
type App = ReaderT AppConfig (StateT AppState IO)

我們的變換器以 IO 為基礎,依次疊加 StateT 與 ReaderT 。在此例中,棧頂是 ReaderT 還是 WriterT 并不重要,但是 IO 必須作為最下層monad。

僅僅幾個monad變換器的疊加,也會使類型簽名迅速變得復雜起來。故此處以 type 關鍵字定義類型別名,以簡化類型的書寫。

缺失的類型參數呢?

或許你已注意到,此處的類型別名并沒有我們?yōu)閙onad類型所常添加的類型參數 a:

-- file: ch18/UglyStack.hs
type App2 a = ReaderT AppConfig (StateT AppState IO) a

在常規(guī)的類型簽名用例下, App 和 App2 不會遇到問題。但如果想以此類型為基礎構建其他類型,兩者的區(qū)別就顯現出來了。

例如我們想另加一層monad變換器,編譯器會允許 WriterT[String]Appa 但拒絕 WriterT[String]App2a 。

其中的理由是:Haskell不允許對類型別名的部分應用。 App 不需要類型參數,故沒有問題。另一方面,因為 App2 需要一個類型參數,若想基于 App2 構造其他的類型,則必須為這個類型參數提供一個類型。

這一限制僅適用于類型別名,當構建monad棧時,通常的做法是用 newtype 來封裝(接下來的部分就會看到這類例子)。 因此實際應用中很少出現這種問題。

[譯注:類似于函數的部分應用,“類型別名的部分應用”指的是在應用類型別名時,給出的參數數量少于定義中的參數數量。在以上例子中, App 是一個完整的應用,因為在其定義 typeApp=... 中,沒有類型參數;而 App2 卻是個部分應用,因為在其定義 typeApp2a=... 中,還需要一個類型參數 a 。]

我們monad棧的執(zhí)行函數很簡單:

-- file: ch18/UglyStack.hs
runApp :: App a -> Int -> IO (a, AppState)
runApp k maxDepth =
    let config = AppConfig maxDepth
        state = AppState 0
    in runStateT (runReaderT k config) state

對 runReaderT 的應用移除了 ReaderT 變換器的包裝,之后 runStateT 移除了 StateT 的包裝,最后的結果便留在 IO monad中。

和先前的版本相比,我們的修改并未使代碼復雜太多,但現在函數卻能記錄目前的路徑,和達到的最大深度:

constrainedCount :: Int -> FilePath -> App [(FilePath, Int)]
constrainedCount curDepth path = do
    contents <- liftIO . listDirectory $ path
    cfg <- ask
    rest <- forM contents $ \name -> do
        let newPath = path </> name
        isDir <- liftIO $ doesDirectoryExist newPath
        if isDir && curDepth < cfgMaxDepth cfg
          then do
            let newDepth = curDepth + 1
            st <- get
            when (stDeepestReached st < newDepth) $
              put st {stDeepestReached = newDepth}
            constrainedCount newDepth newPath
          else return []
    return $ (path, length contents) : concat rest

在這個例子中如此運用monad變換器確實有些小題大做,因為這僅僅是個簡單函數,其并沒有因此得到太多的好處。但是這個方法的實用性在于,可以將其 輕易擴展以解決更加復雜的問題 。

大部分指令式的應用可以使用和這里的 App monad類似的方法,在monad棧中編寫。在實際的程序中,或許需要攜帶更復雜的配置數據,但依舊可以使用 ReaderT 以保持其只讀,并只在需要時暴露配置;或許有更多可變狀態(tài)需要管理,但依舊可以使用 StateT 封裝它們。

隱藏細節(jié)

使用常規(guī)的 newtype 技術,便可將細節(jié)與接口分離開:

newtype MyApp a = MyA
  { runA :: ReaderT AppConfig (StateT AppState IO) a
  } deriving (Monad, MonadIO, MonadReader AppConfig,
              MonadState AppState)

runMyApp :: MyApp a -> Int -> IO (a, AppState)
runMyApp k maxDepth =
    let config = AppConfig maxDepth
        state = AppState 0
    in runStateT (runReaderT (runA k) config) state

若只導出 MyApp 類構造器和 runMyApp 執(zhí)行函數,客戶端的代碼就無法知曉這個monad的內部結構是否是monad棧了。

此處,龐大的 deriving 子句需要 GeneralizedNewtypeDeriving 語言編譯選項。編譯器可以為我們生成這些實例,這看似十分神奇,究竟是如何做到的呢?

早先,我們提到 mtl 庫為每個monad變換器都提供了一系列實例。例如 IO monad實現了 MonadIO ,若下層monad是 MonadIO 的實例,那么 mtl 也將為其對應的 StateT 構建一個 MonadIO 的實例,類似的事情也發(fā)生在 ReaderT 上。

因此,這其中并無太多神奇之處:位于monad棧頂層的monad變換器,已是所有我們聲明的 deriving 子句中的類型類的實例,我們做的只不過是重新派生這些實例。這是 mtl 精心設計的一系列類型類和實例完美配合的結果。除了基于 newtype 聲明的常規(guī)的自動推導以外并沒有發(fā)生什么。

[譯注:注意到此處 newtypeMyAppa 只是喬裝過的 ReaderTAppConfig(StateTAppStateIO)a 。因此我們可以列出 MyAppa 這個monad棧的全貌(自頂向下):

  • ReaderTAppConfig (monad變換器)
  • StateTAppState (monad變換器)
  • IO (monad)

注意這個monad棧和 deriving 子句中類型類的相似度。這些實例都可以自動派生: MonadIO 實例自底層派生上來, MonadStateT 從中間一層派生,而 MonadReader 實例來自頂層。所以雖然 newtypeMyAppa 引入了一個全新的類型,其實例是可以通過內部結構自動推導的。]

練習

  1. 修改 App 類型別名以交換 ReaderT 和 StateT 的位置,這一變換對執(zhí)行函數 runApp 會帶來什么影響?
  2. 為 App monad棧添加 WriterT 變換器。 相應地修改 runApp 。
  3. 重寫 contrainedCount 函數,在為 App 新添加的 WriterT 中記錄結果。

[譯注:第一題中的 StateT 原為 WriterT ,鑒于 App 定義中并無 WriterT ,此處應該指的是 StateT ]

深入Monad棧中

至今,我們了解了對monad變換器的簡單運用。對 mtl 庫的便利組合拼接使我們免于了解monad棧構造的細節(jié)。我們確實已掌握了足以幫助我們簡化大量常見編程任務的monad變換器相關知識。

但有時,為了實現一些實用的功能,還是我們需要了解 mtl 庫并不便利的一面。這些任務可能是將定制的monad置于monad棧底,也可能是將定制的monad變換器置于monad變換器棧中的某處。為了解其中潛在的難度,我們討論以下例子。

假設我們有個定制的monad變換器 CustomT :

-- file: ch18/CustomT.hs
newtype CustomT m a = ...

在 mtl 提供的框架中,每個位于棧上的monad變換器都將其下層monad的API暴露出來。這是通過提供大量的類型類實例來實現的。遵從這一模式的規(guī)則,我們也可以實現一系列的樣板實例:

-- file: ch18/CustomT.hs
instance MonadReader r m => MonadReader r (CustomT m) where
    ...

instance MonadIO m => MonadIO (CustomT m) where
    ...

若下層monad是 MonadReader 的實例,則 CustomT 也可作為 MonadReader 的實例: 實例化的方法是將所有相關的API調用轉接給其下層實例的相應函數。經過實例化之后,上層的代碼就可以將monad棧作為一個整體,當作 MonadReader 的實例,而不再需要了解或關心到底是其中的哪一層提供了具體的實現。

不同于這種依賴類型類實例的方法,我們也可以顯式指定想要使用的API。 MonadTrans 類型類定義了一個實用的函數 lift :

ghci> :m +Control.Monad.Trans
ghci> :info MonadTrans
class MonadTrans t where lift :: (Monad m) => m a -> t m a
      -- Defined in Control.Monad.Trans

這個函數接受來自monad棧中,當前棧下一層的monad動作,并將這個動作變成,或者說是 抬舉 到現在的monad變換器中。每個monad變換器都是 MonadTrans 的實例。

lift 這個名字是基于此函數與 fmap 和 liftM 目的上的相似度的。這些函數都可以從類型系統(tǒng)的下一層中把東西提升到我們目前工作的這一層。它們的區(qū)別是:

fmap將純函數提升到functor層次liftM將純函數提升到monad層次lift將一monad動作,從monad棧中的下一層提升到本層
[譯注:實際上 liftM 間接調用了 fmap ,兩個函數在效果上是完全一樣的。譯者認為,當操作對象是monad(所有的monad都是functor)的時候,使用其中的哪一個只是思考方法上的不同。]

現在重新考慮我們在早些時候定義的 App monad棧 (之前我們將其包裝在 newtype 中):

-- file: ch18/UglyStack.hs
type App = ReaderT AppConfig (StateT AppState IO)

若想訪問 StateT 所攜帶的 AppState ,通常需要依賴 mtl 的類型類實例來為我們處理組合工作:

-- file: ch18/UglyStack.hs
implicitGet :: App AppState
implicitGet = get

通過將 get 函數從 StateT 中抬舉進 ReaderT , lift 函數也可以實現同樣的效果:

-- file: ch18/UglyStack.hs
explicitGet :: App AppState
explicitGet = lift get

顯然當 mtl 可以為我們完成這一工作時,代碼會變得更清晰。但是 mtl 并不總能完成這類工作。

何時需要顯式的抬舉?

我們必須使用 lift 的一個例子是:當在一個monad棧中,同一個類型類的實例出現了多次時:

-- file: ch18/StackStack.hs
type Foo = StateT Int (State String)

若此時我們試著使用 MonadState 類型類中的 put 動作,得到的實例將是 StateTInt ,因為這個實例在monad棧頂。

-- file: ch18/StackStack.hs
outerPut :: Int -> Foo ()
outerPut = put

在這個情況下,唯一能訪問下層 State monad的 put 函數的方法是使用 lift :

-- file: ch18/StackStack.hs
innerPut :: String -> Foo ()
innerPut = lift . put

有時我們需要訪問多于一層以下的monad,這時我們必須組合 lift 調用。每個函數組合中的 lift 將我們帶到更深的一層。

-- file: ch18/StackStack.hs
type Bar = ReaderT Bool Foo

barPut :: String -> Bar ()
barPut = lift . lift . put

正如以上代碼所示,當需要用 lift 的時候,一個好習慣是定義并使用包裹函數來為我們完成抬舉工作。因為這種在代碼各處顯式使用lift的方法使代碼變得混亂。另一個顯式lift的缺點在于,其硬編碼了monad棧的層次細節(jié),這將使日后對monad棧的修改變得復雜。

構建以理解Monad變換器

為了深入理解monad變換器通常是如何運作的,在本節(jié)我們將自己構建一個monad變換器,期間一并討論其中的組織結構。我們的目標簡單而實用: MaybeT 。但是 mtl 庫意外地并沒有提供它。

[譯注:如果想使用現成的 MaybeT ,現在你可以在 Hackage 上的 transformers 庫中找到它。]

這個monad變換器修改monad的方法是:將下層monad ma 的類型參數包裝在 Maybe 中,以得到類型 m(Maybea) 。正如 Maybe monad一樣,若在 MaybeT monad變換器中調用 fail ,則計算將提早結束執(zhí)行。

為使 m(Maybea) 成為 Monad 的實例,其必須有個獨特的類型。這里我們通過 newtype 聲明來實現:

-- file: ch18/MaybeT.hs
newtype MaybeT m a = MaybeT
  { runMaybeT :: m (Maybe a) }

現在需要定義三個標準的monad函數。其中最復雜的是 (>>=) ,它的實現也闡明了我們實際上在做什么。在開始研究其操作之前,不妨先看看其類型:

-- file: ch18/MaybeT.hs
bindMT :: (Monad m) => MaybeT m a -> (a -> MaybeT m b) -> MaybeT m b

為理解其類型簽名,回顧之前在十五章中對“多參數類型類”討論。此處我們想使 部分類型MaybeTm 成為 Monad 的實例。這個部分類型擁有通常的單一類型參數 a ,這樣便能滿足 Monad 類型類的要求。

[譯注: MaybeT 的完整定義是 MaybeTma ,因此 MaybeTm 只是部分應用。]

理解以下 (>>=) 實現的關鍵在于: do 代碼塊里的代碼是在 下層 monad中執(zhí)行的,無論這個下層monad是什么。

-- file: ch18/MaybeT.hs
x `bindMT` f = MaybeT $ do
    unwrapped <- runMaybeT x
    case unwrapped of
      Nothing -> return Nothing
      Just y -> runMaybeT (f y)

我們的 runMaybeT 函數解開了在 x 中包含的結果。進而,注意到 <- 符號是 (>>=) 的語法糖:monad變換器必須使用其下層monad的 (>>=) 。而最后一部分對 unwrapped 的結構分析( case 表達式),決定了我們是要短路當前計算,還是將計算繼續(xù)下去。最后,觀察表達式的最外層。為了將下層monad再次藏起來,這里必須用 MaybeT 構造器包裝結果。

剛才展示的 do 標記看起來更容易閱讀,但是其將我們依賴下層monad的 (>>=) 函數的事實也藏了起來。下面提供一個更符合語言習慣的 MaybeT 的 (>>=) 實現:

-- file: ch18/MaybeT.hs
x `altBindMT` f =
    MaybeT $ runMaybeT x >>= maybe (return Nothing) (runMaybeT . f)

現在我們了解了 (>>=) 在干些什么。關于 return 和 fail 無需太多解釋, Monad 實例也不言自明:

-- file: ch18/MaybeT.hs
returnMT :: (Monad m) => a -> MaybeT m a
returnMT a = MaybeT $ return (Just a)

failMT :: (Monad m) => t -> MaybeT m a
failMT _ = MaybeT $ return Nothing

instance (Monad m) => Monad (MaybeT m) where
  return = returnMT
  (>>=) = bindMT
  fail = failM

建立Monad變換器

為將我們的類型變成monad變換器,必須提供 MonadTrans 的實例,以使用戶可以訪問下層monad:

-- file: ch18/MaybeT.hs
instance MonadTrans MaybeT where
    lift m = MaybeT (Just `liftM` m)

下層monad以類型 a 開始:我們“注入” Just 構造器以使其變成需要的類型: Maybea 。進而我們通過 MaybeT 藏起下層monad。

更多的類型類實例

在定義好 MonadTrans 的實例后,便可用其來定義其他大量的 mtl 類型類實例了:

-- file: ch18/MaybeT.hs
instance (MonadIO m) => MonadIO (MaybeT m) where
  liftIO m = lift (liftIO m)

instance (MonadState s m) => MonadState s (MaybeT m) where
  get = lift get
  put k = lift (put k)

-- ... 對 MonadReader,MonadWriter等的實例定義同理 ...

由于一些 mtl 類型類使用了函數式依賴,有些實例的聲明需要GHC大大放寬其原有的類型檢查規(guī)則。(若我們忘記了其中任意的 LANGUAGE 指令,編譯器會在其錯誤信息中提供建議。)

-- file: ch18/MaybeT.hs
{-# LANGUAGE FlexibleInstances, MultiParamTypeClasses,
             UndecidableInstances #-}

是花些時間來寫這些樣板實例呢,還是顯式地使用 lift 呢?這取決于這個monad變換器的用途。 如果我們只在幾種有限的情況下使用它,那么只提供 MonadTrans 實例就夠了。在這種情況下,也無妨提供一些依然有意義的實例,比如 MonadIO。另一方面,若我們需要在大量的情況下使用這一monad變換器,那么花些時間來完成這些實例或許也不錯。

以Monad棧替代Parse類型

現在我們已開發(fā)了一個支持提早退出的monad變換器,可以用其來輔助開發(fā)了。例如,此處若想處理解析一半失敗的情況,便可以用這一以我們的需求定制的monad變換器來替代我們在第十章“隱式狀態(tài)”一節(jié)開發(fā)的 Parse 類型。

練習

  1. 我們的Parse monad還不是之前版本的完美替代。因為其用的是 Maybe 而不是 Either 來代表結果。因此在失敗時暫時無法提供任何有用的信息。

構建一個 EitherTs (其中 s 是某個類型)來表示結果,并用其實現更適合的 Parse monad以在解析失敗時匯報具體錯誤信息。

或許在你探索Haskell庫的途中,在 Control.Monad.Error 遇到過一個 Either 類型的 Monad 實例。我們建議不要參照它來完成你的實現,因為它的設計太局限了:雖然其將 EitherString 變成一個monad,但實際上把 Either 的第一個類型參數限定為 String 并非必要。

提示: 若你按照這條建議來做,你的定義中或許需要使用 FlexibleInstances 語言擴展。

注意變換器堆疊順序

從早先使用 ReaderT 和 StateT 的例子中,你或許會認為疊加monad變換器的順序并不重要。事實并非如此,考慮在 State 上疊加 StateT 的情況,或許會助于你更清晰地意識到:堆疊的順序確實產生了結果上的區(qū)別:類型 StateTInt(StateString) 和類型 StateTString(StateInt) 或許攜帶的信息相同,但它們卻無法互換使用。疊加的順序決定了我們是否要用 lift 來取得狀態(tài)中的某個部分。

下面的例子更加顯著地闡明了順序的重要性。假設有個可能失敗的計算,而我們想記錄下在什么情況下其會失?。?/p>

-- file: ch18/MTComposition.hs
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Writer
import MaybeT

problem :: MonadWriter [String] m => m ()
problem = do
  tell ["this is where i fail"]
  fail "oops"

那么這兩個monad棧中的哪一個會帶給我們需要的信息呢?

type A = WriterT [String] Maybe

type B = MaybeT (Writer [String])

a :: A ()
a = problem

b :: B ()
b = problem

我們在 ghci 中試試看:

ghci> runWriterT a
Loading package mtl-1.1.0.1 ... linking ... done.
Nothing
ghci> runWriter $ runMaybeT b
(Nothing,["this is where i fail"])

看看執(zhí)行函數的類型簽名,其實結果并不意外:

ghci> :t runWriterT
runWriterT :: WriterT w m a -> m (a, w)
ghci> :t runWriter . runMaybeT
runWriter . runMaybeT :: MaybeT (Writer w) a -> (Maybe a, w)

在 Maybe 上疊加 WriterT 的策略使 Maybe 成為下層monad,因此 runWriterT 必須給我們以 Maybe 為類型的結果。在測試樣例中,我們只會在不出現任何失敗的情況下才能獲得日志!

疊加monad變換器類似于組合函數:如果我們改變函數應用的順序,那么我們并不會對得到不同的結果感到意外。同樣的道理也適用于對monad變換器的疊加。

縱觀Monad與Monad變換器

本節(jié),讓我們暫別細節(jié),討論一下用monad和monad變換器編程的優(yōu)缺點。

對純代碼的干涉

在實際編程中,使用monad的最惱人之處或許在于其阻礙了我們使用純代碼。很多實用的純函數需要一個monad版的類似函數,而其monad版只是加上一個占位參數 m 供monad類型構造器填充:

ghci> :t filter
filter :: (a -> Bool) -> [a] -> [a]
ghci> :i filterM
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
      -- Defined in Control.Monad

然而,這種覆蓋是有限的:標準庫中并不總能提供純函數的monad版本。

其中有一部分歷史原因:Eugenio Moggi于1988年引入了使用monad編程的思想。而當時Haskell 1.0標準尚在開發(fā)中?,F今版本的 Prelude 中的大部分函數可以追溯到于1990發(fā)布的Haskell 1.0。在1991年,Philip Wadler開始為更多的函數式編程聽眾作文,闡述monad的潛力。從那時起,monad開始用于實踐。

直到1996年Haskell 1.3標準發(fā)布之時,monad才得到了支持。但是在那時,語言的設計者已經受制于維護向前兼容性: 它們無法改變 Prelude 中的函數簽名,因為那會破壞現有的代碼。

從那以后,Haskell社區(qū)學會了很多合適的抽象。因此我們可以寫出不受這一純函數/monad函數分裂影響的代碼。你可以在 Data.Traversable 和 Data.Foldable 中找到這些思想的精華。

盡管它們極具吸引力,由于版面的限制。我們不會在本書中涵蓋相關內容。但如果你能輕易理解本章內容,自行理解它們也不會有問題。

在理想世界里,我們是否會與過去斷絕,并讓 Prelude 包含 Traversable 和 Foldable 類型呢?或許不會,因為學習Haskell本身對新手來說已經是個相當刺激的歷程了。在我們已經了解functor和monad之后, Foldable 和 Traversable 的抽象是十分容易理解的。但是對學習者來說這意味著擺在他們面前的是更多純粹的抽象。若以教授語言為目的, map 操作的最好是列表,而不是functor。

[譯注:實際上,自GHC 7.10開始, Foldable 和 Traversable 已經進入了 Prelude 。一些函數的類型簽名會變得更加抽象(以GHC 7.10.1為例):

ghci-7.10.1> :t mapM
mapM :: (Monad m, Traversable t) => (a -> m b) -> t a -> m (t b)
ghci-7.10.1> :t foldl
foldl :: Foldable t => (b -> a -> b) -> b -> t a -> b

這并不是一個對初學者友好的改動,但由于新的函數只是舊有函數的推廣形式,使用舊的函數簽名依舊可以通過類型檢查:

ghci-7.10.1> :t (mapM :: Monad m => (a -> m b) -> [a] -> m [b])
(mapM :: Monad m => (a -> m b) -> [a] -> m [b])
  :: Monad m => (a -> m b) -> [a] -> m [b]
ghci-7.10.1> :t (foldl :: (b -> a -> b) -> b -> [a] -> b)
(foldl :: (b -> a -> b) -> b -> [a] -> b)
  :: (b -> a -> b) -> b -> [a] -> b

若在學習過程中遇到障礙,不妨暫且以舊的類型簽名來理解它們。]

對次序的過度限定

我們使用monad的一個基本原因是:其允許我們指定效果發(fā)生的次序。再看看我們早先寫的一小段代碼:

-- file: ch18/MTComposition.hs
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Writer
import MaybeT

problem :: MonadWriter [String] m => m ()
problem = do
  tell ["this is where i fail"]
  fail "oops"

因為我們在monad中執(zhí)行, tell 的效果可以保證發(fā)生在 fail 之前。這里的問題在于,這個次序并不必要,但是我們卻得到了這樣的次序保證。編譯器無法任意安排monad式代碼的次序,即便這么做能使代碼效率更高。

[譯注:解釋一下這里的“次序并不必要”?;仡欀皩ΟB加次序問題的討論:

type A = WriterT [String] Maybe

type B = MaybeT (Writer [String])

a :: A ()
a = problem
-- runWriterT a == Nothing

b :: B ()
b = problem
-- runWriter (runMaybeT b) == (Nothing, ["this is where i fail"])

下面把注意力集中于 a : 注意到 runWriterTa==Nothing , tell 的結果并不需要,因為接下來的 fail 取消了計算,將之前的結果拋棄了。利用這個事實,可以得知讓 fail 先執(zhí)行效率更高。同時注意對 fail 和 tell 的實際處理來自monad棧的不同層,所以在一定限制下調換某些操作的順序會不影響結果。但是由于這個monad棧本身也要是個monad,使這種本來可以進行的交換變得不可能了。]

運行時開銷

最后,當我們使用monad和monad變換器時,需要付出一些效率的代價。 例如 State monad攜帶狀態(tài)并將其放在一個閉包中。在Haskell的實現中,閉包的開銷或許廉價但絕非免費。

Monad變換器把其自身的開銷附加在了其下層monad之上。每次我們使用 (>>=) 時,MaybeT變換器便需要包裝和解包。而由 ReaderT , StateT 和 MaybeT 依次疊加組成的monad棧,在每次使用 (>>=) 時,更是有一系列的簿記工作需要完成。

一個足夠聰明的編譯器或許可以將這些開銷部分,甚至于全部消除。但是那種深度的復雜工作尚未廣泛適用。

但是依舊有些相對簡單技術可以避免其中的一些開銷,版面的限制只允許我們在此做簡單描述。例如,在continuation monad中,對 (>>=) 頻繁的包裝和解包可以避免,僅留下執(zhí)行效果的開銷。所幸的是使用這種方法所要考慮的大部分復雜問題,已經在函數庫中得到了處理。

這一部分的工作在本書寫作時尚在積極的開發(fā)中。如果你想讓你對monad變換器的使用更加高效,我們推薦在Hackage中尋找相關的庫或是在郵件列表或IRC上尋求指引。

缺乏靈活性的接口

若我們只把 mtl 當作黑盒,那么所有的組件將很好地合作。但是若我們開始開發(fā)自己的monad和monad變換器,并想讓它們于 mtl 提供的組件配合,這種缺陷便顯現出來了。

例如,我們開發(fā)一個新的monad變換器 FooT ,并想沿用 mtl 中的模式。我們就必須實現一個類型類 MonadFoo 。若我們想讓其更好地和 mtl 配合,那么便需要提供大量的實例來支持 mtl 中的類型類。

除此之外,還需要為每個 mtl 中的變換器提供 MonadFoo 的實例。大部分的實例實現幾乎是完全一樣的,寫起來也十分乏味。若我們想在 mtl 中集成更多的monad變換器,那么我們需要處理的各類活動部件將達到引入的monad變換器數量的 平方級別 !

公平地看來,這個問題會只影響到少數人。大部分 mtl 的用戶并不需要開發(fā)新的monad。

造成這一 mtl 設計缺陷的原因在于,它是第一個monad變換器的函數庫。想像其設計者投入這個未知的領域,完成了大量的工作以使這個強大的函數庫對于大部分用戶來說做到簡便易用。

一個新的關于monad和變換器的函數庫 monadLib ,修正了 mtl 中大量的設計缺陷。若在未來你成為了一個monad變換器的中堅駭客,這值得你一試。

平方級別的實例定義實際上是使用monad變換器帶來的問題。除此之外另有其他的手段來組合利用monad。雖然那些手段可以避免這類問題,但是它們對最終用戶而言仍不及monad變換器便利。幸運的是,并沒有太多基礎而泛用的monad變換器需要去定義實現。

綜述

Monad在任何意義下都不是處理效果和類型的終極途徑。它只是在我們探索至今,處理這類問題最為實用的技術。語言的研究者們一直致力于找到可以揚長避短的替代系統(tǒng)。

盡管在使用它們時我們必須做出妥協,monad和monad變換器依舊提供了一定程度上的靈活度和控制,而這在指令式語言中并無先例。 僅僅幾個聲明,我們就可以給分號般基礎的東西賦予嶄新的意義。

[譯注:此處的分號應該指的是 do 標記中使用的分號。]

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號