第十八章: Monad變換器

2018-02-24 15:49 更新

第十八章: Monad變換器

動機: 避免樣板代碼

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

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

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

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

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

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

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

簡單的Monad變換器實例

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

-- 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

現(xiàn)在看看如何使用 Writer monad 實現(xiàn)相同的目標。由于這個monad允許隨時記下數(shù)值,所以并不需要我們顯示地去構(gòu)建結(jié)果。

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

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

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

由于所需的目錄遍歷操作需要訪問 IO monad,因此我們將writer功能累加在 IO monad之上。通過將monad變換器與原有monad結(jié)合,我們得到了類型簽名: 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í)行函數(shù):

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

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

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

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

Monad和Monad變換器中的模式

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

為說明這些規(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 函數(shù)。通過一個類型為 r->r 的函數(shù),它可臨時修改當前的環(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 提供了關(guān)于 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。由于混合的結(jié)果也是個monad,我們可以憑此為基礎(chǔ)再疊加上一層monad變換器。事實上,這么做十分常見。但在什么情況下才需要創(chuàng)建這樣的monad呢?

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

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

舉個多重疊加的moand變換器的例子,這里是之前開發(fā)的 countEntries 函數(shù)。我們想限制其遞歸的深度,并記錄下它在執(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 來記錄配置數(shù)據(jù),數(shù)據(jù)的內(nèi)容表示最大允許的遞歸深度。同時也使用了 StateT 來記錄在實際遍歷過程中所達到的最大深度。

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

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

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

缺失的類型參數(shù)呢?

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

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

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

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

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

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

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

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

-- 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 的包裝,最后的結(jié)果便留在 IO monad中。

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

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變換器確實有些小題大做,因為這僅僅是個簡單函數(shù),其并沒有因此得到太多的好處。但是這個方法的實用性在于,可以將其 輕易擴展以解決更加復雜的問題 。

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

隱藏細節(jié)

使用常規(guī)的 newtype 技術(shù),便可將細節(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 類構(gòu)造器和 runMyApp 執(zhí)行函數(shù),客戶端的代碼就無法知曉這個monad的內(nèi)部結(jié)構(gòu)是否是monad棧了。

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

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

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

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

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

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

練習

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

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

深入Monad棧中

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

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

假設(shè)我們有個定制的monad變換器 CustomT :

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

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

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

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

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

不同于這種依賴類型類實例的方法,我們也可以顯式指定想要使用的API。 MonadTrans 類型類定義了一個實用的函數(shù) 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

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

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

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

現(xiàn)在重新考慮我們在早些時候定義的 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 函數(shù)從 StateT 中抬舉進 ReaderT , lift 函數(shù)也可以實現(xiàn)同樣的效果:

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

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

何時需要顯式的抬舉?

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

-- 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 函數(shù)的方法是使用 lift :

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

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

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

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

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

構(gòu)建以理解Monad變換器

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

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

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

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

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

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

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

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

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

理解以下 (>>=) 實現(xiàn)的關(guān)鍵在于: 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 函數(shù)解開了在 x 中包含的結(jié)果。進而,注意到 <- 符號是 (>>=) 的語法糖:monad變換器必須使用其下層monad的 (>>=) 。而最后一部分對 unwrapped 的結(jié)構(gòu)分析( case 表達式),決定了我們是要短路當前計算,還是將計算繼續(xù)下去。最后,觀察表達式的最外層。為了將下層monad再次藏起來,這里必須用 MaybeT 構(gòu)造器包裝結(jié)果。

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

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

現(xiàn)在我們了解了 (>>=) 在干些什么。關(guān)于 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 構(gòu)造器以使其變成需要的類型: 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 類型類使用了函數(shù)式依賴,有些實例的聲明需要GHC大大放寬其原有的類型檢查規(guī)則。(若我們忘記了其中任意的 LANGUAGE 指令,編譯器會在其錯誤信息中提供建議。)

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

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

以Monad棧替代Parse類型

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

練習

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

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

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

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

注意變換器堆疊順序

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

下面的例子更加顯著地闡明了順序的重要性。假設(shè)有個可能失敗的計算,而我們想記錄下在什么情況下其會失?。?/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í)行函數(shù)的類型簽名,其實結(jié)果并不意外:

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 為類型的結(jié)果。在測試樣例中,我們只會在不出現(xiàn)任何失敗的情況下才能獲得日志!

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

縱觀Monad與Monad變換器

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

對純代碼的干涉

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

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

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

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

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

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

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

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

[譯注:實際上,自GHC 7.10開始, Foldable 和 Traversable 已經(jīng)進入了 Prelude 。一些函數(shù)的類型簽名會變得更加抽象(以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

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

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 的結(jié)果并不需要,因為接下來的 fail 取消了計算,將之前的結(jié)果拋棄了。利用這個事實,可以得知讓 fail 先執(zhí)行效率更高。同時注意對 fail 和 tell 的實際處理來自monad棧的不同層,所以在一定限制下調(diào)換某些操作的順序會不影響結(jié)果。但是由于這個monad棧本身也要是個monad,使這種本來可以進行的交換變得不可能了。]

運行時開銷

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

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

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

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

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

缺乏靈活性的接口

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

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

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

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

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

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

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

綜述

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

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

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

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號