第十四章:Monads

2018-08-12 22:19 更新

簡介?

第 7 章:I/O 中,我們討論了 IO monad,那時我們刻意把精力集中在如何與外界交互上,并沒有討論monad是什么。

第 7 章:I/O 中我們看到 IO Monad確實很好用;除了在語法上不同之外,在 IO monad中寫代碼跟其他命令式語言基本沒有什么區(qū)別。

在前面的章節(jié)中,我們在解決一些實際問題的時候引入了一些數(shù)據(jù)結構,很快我們就會知道它們其實就是monads。我們想告訴你的是,在解決某些問題的時候,monad通常是一個非常直觀且實用的工具。本章我們將定義一些monads并告訴你它有多么簡單。

回顧之前代碼?

Maybe鏈?

我們先看看我們在 第 10 章:代碼案例學習:解析二進制數(shù)據(jù)格式 寫的 parseP5 函數(shù):

-- file: ch10/PNM.hs
matchHeader :: L.ByteString -> L.ByteString -> Maybe L.ByteString

-- "nat" here is short for "natural number"
getNat :: L.ByteString -> Maybe (Int, L.ByteString)

getBytes :: Int -> L.ByteString
         -> Maybe (L.ByteString, L.ByteString)

    parseP5 s =
      case matchHeader (L8.pack "P5") s of
        Nothing -> Nothing
        Just s1 ->
          case getNat s1 of
            Nothing -> Nothing
            Just (width, s2) ->
              case getNat (L8.dropWhile isSpace s2) of
                Nothing -> Nothing
                Just (height, s3) ->
                  case getNat (L8.dropWhile isSpace s3) of
                    Nothing -> Nothing
                    Just (maxGrey, s4)
                      | maxGrey > 255 -> Nothing
                      | otherwise ->
                          case getBytes 1 s4 of
                            Nothing -> Nothing
                            Just (_, s5) ->
                              case getBytes (width * height) s5 of
                                Nothing -> Nothing
                                Just (bitmap, s6) ->
                                  Just (Greymap width height maxGrey bitmap, s6)

這個函數(shù)要是再復雜一點,就要超出屏幕的右邊了;當時我們使用 (>>?) 操作符避免了這種情況:

-- file: ch10/PNM.hs
(>>?) :: Maybe a -> (a -> Maybe b) -> Maybe b
Nothing >>? _ = Nothing
Just v  >>? f = f v

我們對 (>>?) 操作符的類型進行了精挑細選使得它能把一系列返回類型是 Maybe 的函數(shù)串聯(lián)起來;只要一個函數(shù)的返回值能和下一個函數(shù)的參數(shù)類型匹配,我們就能無限串聯(lián)返回類型是 Maybe 的函數(shù)。 (>>?) 的函數(shù)體把細節(jié)隱藏了起來,我們不知道我們通過 (>>?) 串聯(lián)的函數(shù)是由于中間某個函數(shù)返回 Nothing 而中斷了還是所有函數(shù)全部執(zhí)行了。

隱式狀態(tài)?

(>>?) 被用來整理 parseP5 的結構,但是在解析的時候我們還是要一點一點地處理輸入字符串;這使得我們必須把當前處理的值通過一個元組傳遞下去[若干個函數(shù)串聯(lián)了起來,都返回Maybe,作者稱之為Maybe鏈]。Maybe鏈上的每一個函數(shù)把自己處理的結果以及自己沒有解析的剩下的字符串放到元組里面, 并傳遞下去。

-- file: ch10/PNM.hs
parseP5_take2 :: L.ByteString -> Maybe (Greymap, L.ByteString)
parseP5_take2 s =
    matchHeader (L8.pack "P5") s       >>?
    \s -> skipSpace ((), s)           >>?
    (getNat . snd)                    >>?
    skipSpace                         >>?
    \(width, s) ->   getNat s         >>?
    skipSpace                         >>?
    \(height, s) ->  getNat s         >>?
    \(maxGrey, s) -> getBytes 1 s     >>?
    (getBytes (width * height) . snd) >>?
    \(bitmap, s) -> Just (Greymap width height maxGrey bitmap, s)

skipSpace :: (a, L.ByteString) -> Maybe (a, L.ByteString)
skipSpace (a, s) = Just (a, L8.dropWhile isSpace s)

我們又碰到了有著重復行為的模式:處理字符串的時候,某個函數(shù)消耗部分字符串并返回它處理的結果,同時把剩下的字符串傳遞給下一個函數(shù)繼續(xù)處理。但是,這個模式比之前的更糟糕:如果我們要在處理鏈往下傳遞另外一些額外信息,我們必須把傳遞的二元組修改為三元組,這幾乎要修改這個處理鏈上的所有元素!

我們把管理當前字符串的任務從處理鏈上的單個函數(shù)移出來,將它(管理字符串)轉交給串聯(lián)這些單個函數(shù)的函數(shù)完成![譯:比如上面的 (>>?)]

-- file: ch10/Parse.hs
(==>) :: Parse a -> (a -> Parse b) -> Parse b

firstParser ==> secondParser  =  Parse chainedParser
  where chainedParser initState   =
          case runParse firstParser initState of
            Left errMessage ->
                Left errMessage
            Right (firstResult, newState) ->
                runParse (secondParser firstResult) newState

我們把解析狀態(tài)的細節(jié)隱藏在 ParseState 類型中,就連 getStateputState 都不會窺視解析狀態(tài),所以,無論對 ParseState 做怎樣的修改都不會影響已有的代碼。

尋找共同特征?

如果我們仔細分析上面的例子,它們好像沒有什么共同特點。不過有一點比較明顯,它們都想把函數(shù)串聯(lián)起來并試圖隱藏細節(jié)以便我們寫出整潔的代碼。然后,我們先不管那些細節(jié),從更粗略的層面去思考一下。

首先,我們看一看類型聲明:

-- file: ch14/Maybe.hs
data Maybe a = Nothing
             | Just a
-- file: ch11/Parse.hs
newtype Parse a = Parse {
      runParse :: ParseState -> Either String (a, ParseState)
    }

這兩個類型的共同特點是它們都有一個類型參數(shù),因此它們都是范型,對具體的類型一無所知。

然后看一看我們給兩個類型寫的串聯(lián)函數(shù):

ghci> :type (>>?)
(>>?) :: Maybe a -> (a -> Maybe b) -> Maybe b
ghci> :type (==>)
(==>) :: Parse a -> (a -> Parse b) -> Parse b

這兩個函數(shù)的類型非常相似,如果我們把它們的類型構造器替換為一個類型變量,我們會得到一個更加抽象的類型。

-- file: ch14/Maybe.hs
chain :: m a -> (a -> m b) -> m b

最終,在兩種情況下,我們都得到了一個獲取一個普通的值,然后把它“注入”到一個目標類型里面去的函數(shù)。對于 Maybe 類型,這個函數(shù)就是它的一個值構造器 Just ,``Parse``的注入函數(shù)就略微復雜一些。

-- file: ch10/Parse.hs
identity :: a -> Parse a
identity a = Parse (\s -> Right (a, s))

我們不用關心它的實現(xiàn)細節(jié),也不管它有多么復雜;重要的是,這些類型都有一個“注入器”函數(shù),它大致長這樣:

-- file: ch14/Maybe.hs
inject :: a -> m a

在Haskell里面,正是這三個屬性和一些如何使用它們的規(guī)則定義了monad。我們集中總結一下:

  1. 一個類型構造器 m
  2. 一個用于把某個函數(shù)的輸出串聯(lián)到另外一個函數(shù)輸入上的函數(shù),它的類型是 m a -> (a -> m b) -> m b
  3. 一個類型是 a -> m a 類型的函數(shù),它把普通值注入到調用鏈里面,也就是說,它把類型 a 用類型構造器 m 包裝起來。

Maybe 類型的類型構造器 Maybe a ,串聯(lián)函數(shù) (>>?) 以及注入函數(shù) Just 使Maybe成為一個monad。對于 Parse 類型,對應的是類型構造器 Parse a ,串聯(lián)函數(shù) Parse a 以及注入函數(shù) identify 。

對于Monad的串聯(lián)函數(shù)和注入函數(shù)具體應該干什么我們刻意只字未提,因為它幾乎不重要。事實上,正是因為Monad如此簡單,它在Haskell里面無處不在。許多常見的編程模式都用到了monad結構:傳遞隱式數(shù)據(jù),或是短路求值鏈。

Monad 類型類?

在Haskell里面我們可以使用一個類型類(typeclass)來表示“串聯(lián)”以及“注入”的概念以及它們的類型。標準庫的Predule模塊已經(jīng)包含了這樣一個類型類,也就是 Monad 。

-- file: ch14/Maybe.hs
class Monad m where
    -- chain
    (>>=)  :: m a -> (a -> m b) -> m b
    -- inject
    return :: a -> m a

在這里,(>>=) 就是我們的串聯(lián)函數(shù)。 在 串聯(lián)化(Sequencing) 中我們已經(jīng)介紹了它。通常將這個操作符稱呼為“綁定”,因為它把左側運算的結果綁定到右側運算的參數(shù)上。

我們的注入函數(shù)是 return ,在 Return的本色 中講過,選用 return 這個名字有點倒霉。這個關鍵字在命令式語言中被廣泛使用并且有一個非常容易理解的含義。但是在Haskell里面它的含義完全不同;具體來說,在函數(shù)調用鏈中間使用 return 并不會導致調用鏈提前中止;我們可以這樣理解它:它把純值( a 類型)放進(returns)monads( m a 類型)里。

(>>=)returnMonad 這個類型類的核心函數(shù);除此之外,它還定義了另外兩個函數(shù)。一個函數(shù)是 (>>) ,類似于 (>>=) ,它的作用也是串聯(lián),但是它忽略左側的值。

-- file: ch14/Maybe.hs
    (>>) :: m a -> m b -> m b
        a >> f = a >>= \_ -> f

當我們需要按順序執(zhí)行一系列操作的,并且不關心先前的計算結果的時候,可以使用這操作符。這樣也許看起來讓人覺得費解:為什么我們會忽略一個函數(shù)的返回值呢,這樣有什么意義?回想一下,我們之前定義了一個 (==>&) 組合子來專門表達這個概念。另外,考慮一下 print 這樣的函數(shù),它的返回結果是一個占位符,我們沒有必要關心它返回值是什么。

ghci> :type print "foo"
print "foo" :: IO ()

如果我們使用普通的 (>>=) 來串聯(lián)調用,我們必須提供一個新的函數(shù)來忽略參數(shù)(這個參數(shù)是前一個 print 的返回值。)

ghci> print "foo" >>= \_ -> print "bar"
"foo"
"bar"

但是,如果我們使用 (>>) 操作符,那么就可以去掉那個沒什么用的函數(shù)了:

ghci> print "baz" >> print "quux"
"baz"
"quux"

正如我們上面看到的一樣, (>>) 的默認實現(xiàn)是通過 (>>=) 完成的。

Monad類型類另外一個非核心函數(shù)是 fail ,這個函數(shù)接受一個錯誤消息然后讓函數(shù)調用鏈失敗。

Warning

許多Monad的實現(xiàn)并沒有重寫 fail``函數(shù)的默認實現(xiàn),因此在這些Monad的里面, ``fail 將由 error 函數(shù)實現(xiàn)。由于error函數(shù)直接拋出某個異常使得調用者無法捕獲或者無法預期,所以調用 errror 通常是非常不受歡迎的。就算你很清楚在Monad使用 fail 在當前場景下是個明智之選,但是依然非常不推薦使用它。當你以后重構代碼的時候,很有可能這個 fail 函數(shù)在新的語境下無法工作從而導致非常復雜的問題,這種情況太容易發(fā)生了。

回顧一下我們在 第 10 章:代碼案例學習:解析二進制數(shù)據(jù)格式 寫的parse, 里面有一個 Monad 的實例:

-- file: ch10/Parse.hs
instance Monad Parse where
    return = identity
    (>>=) = (==>)
    fail = bail

術語解釋?

可能你對monad的某些慣用語并不熟悉,雖然他們不是正式術語,但是很常見;因此有必要了解一下。

  • “Monadic”僅僅表示“和Monad相關的”。一個monadic 類型就是一個Monad 類型類的實例;一個monadic值就是一個具有monadic類型的值。
  • 當我們說某個東西“是一個monad”的時候,我們其實表達的意思是“這個類型是Monad這個類型類的實例”;作為Monad的實例就有三要素:類型構造器,注入函數(shù),串聯(lián)函數(shù)。
  • 同樣,當我們談到“Foo這個monad”的時候,我們實際上指的是Foo這個類型,只不過Foo是Monad這個類型類的實例。
  • Monadic值的一個別稱是“動作”;這個說法可能源自 I/O Monad 的引入, print "foo" 這樣的monad值會導致副作用。返回類型是monadic值的函數(shù)有時候也被稱之為動作,雖然這樣并不太常見。

使用新的Monad?

我們在介紹Monad的時候,展示了一些之前的代碼,并說明它們其實就是Monad。既然我們慢慢知道m(xù)onad是什么,而且已經(jīng)見識過 Monad 這個類型類;現(xiàn)在就讓我們用學到的知識來寫一個Monad吧。我們先定義它的接口,然后使用它;一旦完成了這些,我們就寫出了自己的Monad!

純粹的Haskell代碼寫起來非常簡潔,但是它不能執(zhí)行IO操作;有時候,我們想記下我們程序的一些操作,但是又不想直接把日志信息寫入文件;就這些需求,我們開發(fā)一個小型庫。

回憶一下我們在 將 glob 模式翻譯為正則表達式 中定義的 globToRegex 函數(shù);我們修改它讓它能夠記住每次它翻譯過的句子。我們又回到了熟悉的恐怖場景:比較同一份代碼的Monadic版本和非Monadic版。

首先,我們可以使用一個 Logger 類型類把處理結果的類型包裝起來。

-- file: ch14/Logger.hs
globToRegex :: String -> Logger String

信息隱藏?

我們將刻意隱藏 Logger 模塊的實現(xiàn)。

-- file: ch14/Logger.hs
module Logger
    (
      Logger
    , Log
    , runLogger
    , record
    ) where

像這樣隱藏實現(xiàn)有兩個好處:它很大程度出上保證了我們對于Monad實現(xiàn)的靈活性,更重要的是,這樣有一個非常簡單的接口。

Logger 類型就是單純的一個類型構造器。我們并沒有將它的值構造器導出,因此 Logger 模塊的使用者沒有辦法自己創(chuàng)建一個 Logger 類型的值,它們對于 Logger 類型能做的就是把它寫在類型簽名上。

Log 類型就是一串字符串的別名,這樣寫是為了讓它可讀性更好。同時我們使用一串字符串來保持實現(xiàn)的簡單。

-- file: ch14/Logger.hs
type Log = [String]

我們給接口的使用者提供了一個 runLogger 函數(shù)來執(zhí)行某個日志操作,而不是直接給他們一個值構造器。這個函數(shù)既回傳了日志紀錄這個操作,同時也回傳了日志信息本身。

-- file: ch14/Logger.hs
runLogger :: Logger a -> (a, Log)

受控的Monad?

Monad類型類沒有提供任何方法使一個monadic的值成為一個普通的值。我們可以使用 return 函數(shù)把一個普通的值“注入”到monad里面;我們也可以用 (>>=) 操作符把一個monadic的值提取出來,但是經(jīng)過操作符處理之后還是回傳一個monadic的值。

很多monads都有一個或者多個類似 runLogger 的函數(shù); IO monad是個例外,通常情況下我們只能退出整個程序來脫離這個monad。

一個Monad函數(shù)在monad內(nèi)部執(zhí)行然后向外返回結果;一般來說這些函數(shù)是把一個Monadic的值脫離Monad成為一個普通值的唯一方法。因此,Monad的創(chuàng)建者對于如何處理這個過程有著完全的控制權。

有的Monad有好幾個執(zhí)行函數(shù)。在我們這個Logger的例子里面,我們可以假設有一些 runLogger 的替代方法:一個僅僅返回日志信息,另外一個可能返回日志操作,然后把日志信息本身丟掉。

日志紀錄?

當我們執(zhí)行一個 Logger 動作的時候,代碼將調用 record 函數(shù)來紀錄某些東西。

-- file: ch14/Logger.hs
record :: String -> Logger ()

由于日志紀錄的過程發(fā)生在Monad的內(nèi)部,因此 record 這個動作并不返回什么有用的信息( () )

通常Monad會提供一些類似 record 這樣的輔助函數(shù);這些函數(shù)也是我們訪問這個Monad某些特定行為的方式。

我們的模塊也把 Logger 定義為了 Monad 的實例。這個實例里面的定義就是使用 Logger 類型所需要的全部東西。

下面就是使用我們的 Logger 類的一個例子:

ghci> let simple = return True :: Logger Bool
ghci> runLogger simple
(True,[])

當我們使用 runLogger 函數(shù)執(zhí)行被記錄的操作之后,會得到一個二元組。二元組的第一個元素是我們代碼的執(zhí)行結果;第二個元素是我們的日志動作執(zhí)行的時候紀錄信息的列表。由于我們沒有紀錄任何東西,所以返回的列表是空;來個有日志信息的例子。

ghci> runLogger (record "hi mom!" >> return 3.1337)
(3.1337,["hi mom!"])

使用 Logger monad?

Logger monad里面我們可以剔除通配符到正則表達式的轉換,代碼如下:

-- file: ch14/Logger.hs
globToRegex cs =
    globToRegex' cs >>= \ds ->
    return ('^':ds)

然后我們來簡單說明一下一些值得注意的代碼風格。我們函數(shù)體在函數(shù)名字下面一行,要這么做,需要添加一些水平的空格;對于匿名函數(shù),我們把它的參數(shù)放在另起的一行,這是monadic代碼通常的組織方式。

回憶一下 (>>=) 的類型:它從 Logger 包裝器中中提取出操作符 (>>=) 左邊的值,然后把取出來的值傳遞給右邊的函數(shù)。右邊的操作數(shù)函數(shù)必須把這個取出來的值用 Logger 包裝起來然后回傳出去。這個操作正如正如 return 一樣:接受一個純值,然后用Monad的類型構造器包裝一下返回。

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type (globToRegex "" >>=)
(globToRegex "" >>=) :: (String -> Logger b) -> Logger b

就算我們寫一個什么都不做的函數(shù),我們也必須使用 return 去包裝具有正確類型的值。

-- file: ch14/Logger.hs
globToRegex' :: String -> Logger String
globToRegex' "" = return "$"

當我們要使用 record 函數(shù)紀錄某些日志的時候,我們采用 (>>) 而不是 (>>=) 來串聯(lián)一系列的日志操作。

-- file: ch14/Logger.hs
globToRegex' ('?':cs) =
    record "any" >>
    globToRegex' cs >>= \ds ->
    return ('.':ds)

(>>) 就是 (>>=) 的一個變種,只不過它會忽略左邊操作的結果;由于 record 函數(shù)的返回值永遠都是 () 因此獲取它的返回值沒有什么意義,直接使用 >> 更簡潔。

另外,我們也可以使用在 串聯(lián)化(Sequencing) 引入的 do 表示法來整理代碼。

-- file: ch14/Logger.hs
globToRegex' ('*':cs) = do
    record "kleene star"
    ds <- globToRegex' cs
    return (".*" ++ ds)

選擇使用 do 表示法還是顯式使用 (>>=) 結合匿名函數(shù)完全取決于個人愛好,但是對于長度超過兩行的代碼來說,幾乎所有人都會選擇使用 do. 這兩種風格有一個非常重要的區(qū)別,我們將會在 還原do的本質 里面介紹。

對于解析單個字符的情況,monadic的代碼幾乎和普通的一樣:

-- file: ch14/Logger.hs
globToRegex' ('[':'!':c:cs) =
    record "character class, negative" >>
    charClass cs >>= \ds ->
    return ("[^" ++ c : ds)
globToRegex' ('[':c:cs) =
    record "character class" >>
    charClass cs >>= \ds ->
    return ("[" ++ c : ds)
globToRegex' ('[':_) =
    fail "unterminated character class"

同時使用puer和monadic代碼?

迄今為止我們看到的Monad好像有一個非常明顯的缺陷:Monad的類型構造器把一個值包裝成一個monadic的值,這樣導致在monad里面使用普通的純函數(shù)有點困難。舉個例子,假設我們有一段運行在monad里面的代碼,它所做的就是返回一個字符串:

ghci> let m = return "foo" :: Logger String

如果我們想知道字符串的長度是多少,我們不能直接調用 length 函數(shù):因為這個字符串被 Logger 這個monad包裝起來了,因此類型并不匹配。

ghci> length m

<interactive>:1:7:
    Couldn't match expected type `[a]'
           against inferred type `Logger String'
    In the first argument of `length', namely `m'
    In the expression: length m
    In the definition of `it': it = length m

我們能做的事情就是下面這樣:

ghci> :type   m >>= \s -> return (length s)
m >>= \s -> return (length s) :: Logger Int

我們使用 (>>=) 把字符串從monad里面取出來,然后使用一個匿名函數(shù)調用 length 接著用 return 把這個字符串重新包裝成 Logger 。

由于這種形式的代碼經(jīng)常在Haskell里面出現(xiàn),因此已經(jīng)有一個類似的操作符存在了。在 Functor 簡介 里面我們介紹了 lifting 這種技術;把一個純函數(shù) Lift 為一個函子通常意味著從一個帶有上下文的特殊值里面取出那個值,然后使用這個普通的值調用純函數(shù),得到結果之后用特定的類型構造器包裝成原來有著上下文的特殊值。

在monad里面,我們需要干同樣的一件事。由于 Monad 這個類型類已經(jīng)提供了 (>>=)return 這兩個函數(shù)處理monadic的值和普通值之間的轉換,因此 liftM 函數(shù)不需要知道m(xù)onad的任何實現(xiàn)細節(jié)。

-- file: ch14/Logger.hs
liftM :: (Monad m) => (a -> b) -> m a -> m b
liftM f m = m >>= \i ->
            return (f i)

當我們把一個類型聲明為 Functor 這個類型類的實例之后,我們必須根據(jù)這個特定的類型實現(xiàn)對應的 fmap 函數(shù);但是, 由于 (>>=)return 對monad的進行了抽象,因此``liftM`` 不需要知道任何monad的任何實現(xiàn)細節(jié)。我們只需要實現(xiàn)一次并配上合適的類型簽名即可。

在標準庫的 Control.Monad 模塊里面已經(jīng)為我們定義好了 liftM 函數(shù)。

我們來看看使用 liftM 對于提升我們代碼可讀性有什么作用;先看看沒有使用 liftM 的代碼:

-- file: ch14/Logger.hs
charClass_wordy (']':cs) =
    globToRegex' cs >>= \ds ->
    return (']':ds)
charClass_wordy (c:cs) =
    charClass_wordy cs >>= \ds ->
    return (c:ds)

然后我們用 liftM 去掉那些 (>>=)) 和匿名函數(shù):

-- file: ch14/Logger.hs
charClass (']':cs) = (']':) `liftM` globToRegex' cs
charClass (c:cs) = (c:) `liftM` charClass cs

正如 fmap 一樣,我們通常用中綴的方式調用 liftM 。可以用這種方式來閱讀這個表達式:把右邊操作得到的monadic的值應用到左邊的純函數(shù)上。

liftM 函數(shù)實在是太有用了,因此 Control.Monad 定義了它的幾個變種,這些變種可以處理更長的參數(shù);我們可以看一看 globToRegex 這個函數(shù)的最后一個分句:

-- file: ch14/Logger.hs
globToRegex' (c:cs) = liftM2 (++) (escape c) (globToRegex' cs)

escape :: Char -> Logger String
escape c
    | c `elem` regexChars = record "escape" >> return ['\\',c]
    | otherwise           = return [c]
  where regexChars = "\\+()^$.{}]|"

上面這段代碼用到的 liftM2 函數(shù)的定義如下:

-- file: ch14/Logger.hs
liftM2 :: (Monad m) => (a -> b -> c) -> m a -> m b -> m c
liftM2 f m1 m2 =
    m1 >>= \a ->
    m2 >>= \b ->
    return (f a b)

它首先執(zhí)行第一個動作,接著執(zhí)行第二個操作,然后把這兩個操作的結果組合起來應用到那個純函數(shù)上并包裝返回的結果。 Control.Monad 里面定義了 liftM liftM2 直到 liftM5

關于Monad的一些誤解?

我們已經(jīng)見識過很多Monad的例子并且對monad也有一些感覺了;在繼續(xù)探討monad之前,有一些廣為流傳的關于monad的觀念需要澄清。你肯定經(jīng)常聽到這些說法,因此你可能已經(jīng)有一些很好的理由來反駁這些謬論了。

  • Monads很難理解 我們已經(jīng)從好幾個實例的問題來說明Monad是如何工作的了,并且我們已經(jīng)知道理解monad最好的方式就是先通過一些具體的例子來進行解釋,然后抽象出這些這些例子共同的東西。
  • Monads僅僅用于 I/O 操作和命令式代碼 雖然我們在Haskell的IO里面使用Monad,但是Monad在其他的地方也非常有用。我們已經(jīng)通過monad串聯(lián)簡單的計算,隱藏復雜的狀態(tài)以及紀錄日志了;然而,Monad的作用我們還只看到冰山一角。
  • 只有Haskell才有Monad Haskell有可能是顯式使用Monad最多的語言,但是在別的語言里面也存在,從C++到OCaml。由于Haskell的 do 表示法,強大的類型系統(tǒng)以及語言的語法使得Monad在Haskell里面非常容易處理。
  • Monads使用來控制求值順序的

創(chuàng)建Logger Monad?

Logger 類的定義非常簡單:

-- file: ch14/Logger.hs
newtype Logger a = Logger { execLogger :: (a, Log) }

它其實就是一個二元組,第一個元素是執(zhí)行動作的結果,第二元素是我們執(zhí)行動作的時候紀錄的日志信息列表。

我們使用 newtype 關鍵字把二元組進行了包裝使它的類型更加清晰易讀。 runLogger 函數(shù)可以從這個Monad里面取出這個元組里面的值;這個函數(shù)其實是 execLogger 的一個別名。

-- file: ch14/Logger.hs
runLogger = execLogger

record 這個函數(shù)將為接收到的日志信息創(chuàng)建一個只包含單個元素的列表。

-- file: ch14/Logger.hs
record s = Logger ((), [s])

這個動作的結果是 () 。

讓我們以 return 開始,構建 Monad 實例;先嘗試一下:它什么都不記錄,然后把結果存放在二元組里面。

-- file: ch14/Logger.hs
instance Monad Logger where
    return a = Logger (a, [])

(>>=) 的定義更有趣,當然它也是monad的核心。 (>>=) 把一個普通的值和一個monadic的函數(shù)結合起來,得到新的運算結果和一個新的日志信息。

-- file: ch14/Logger.hs
    -- (>>=) :: Logger a -> (a -> Logger b) -> Logger b
    m >>= k = let (a, w) = execLogger m
                  n      = k a
                  (b, x) = execLogger n
              in Logger (b, w ++ x)

我們看看這段代碼里面發(fā)生了什么。首先使用 runLogger 函數(shù)從動作 m 取出結果 a ,然后把它傳遞給monadic函數(shù) k; 接著我們又取出 b ;最后把 wx 拼接得到一個新的日志。

順序的日志,而不是順序的求值?

我們定義的 (>>=) 保證了新輸出的日志一定在之前的輸出的日志之后。但是這并不意味著 ab 的求值是順序的:(>>=) 操作符是惰性求值的。

正如Monad的很多其他行為一樣,求值的嚴格性是由Monad的實現(xiàn)者控制的,并不是所有Monad的共同性質。事實上,有一些Monad同時有幾種特性,每一種都有著不同程度的嚴格性(求值)。

Writer monad?

我們創(chuàng)建的 Logger monad實際上是標準庫里面 Writer Monad的一個特例;Writer Monad可以在 mtl 包里面的 Control.Monad.Writer 模塊找到。我們會在 第 6 章:使用類型類 里面介紹 Writer 的用法。

Maybe monad?

Maybe 應該是最簡單的Monad了。它代表了一種可能不會產(chǎn)生計算結果的計算過程。

-- file: ch14/Maybe.hs
instance Monad Maybe where
    Just x >>= k  =  k x
    Nothing >>= _ =  Nothing

    Just _ >> k   =  k
    Nothing >> _  =  Nothing

    return x      =  Just x

    fail _        =  Nothing

當我們使用 (>>=) 或者 (>>) 串聯(lián)一些 Maybe 計算的時候,如果這些計算中的任何一個返回了 Nothing , (>>=)(>>) 就不會對余下的任何計算進行求值。

值得一提的是,整個調用鏈并不是完全短路的。每一個 (>>=) 或者 (>>) 仍然會匹配它左邊的 Nothing 然后給右邊的函數(shù)一個 Nothing, 直到達到調用鏈的末端。這一點很容易被遺忘:當調用鏈中某個計算失敗的時候,之前計算的結果,余下的調用鏈以及使用的 Nothing 值在運行時的開銷是廉價的,但并不是完全沒有開銷。

執(zhí)行Maybe monad?

適合執(zhí)行 Maybe Monad的函數(shù)是 maybe (“執(zhí)行”一個monad意味著取出Monad里面包含的值,移除Monad類的包裝)

-- file: ch14/Maybe.hs
maybe :: b -> (a -> b) -> Maybe a -> b
maybe n _ Nothing  = n
maybe _ f (Just x) = f x

如果第三個參數(shù)是 Nothing , maybe 將使用第一個參數(shù)作為返回值;而第二個參數(shù)則是在 Just 值構造器里面進行包裝值的函數(shù)。

由于 Maybe 類型非常簡單,直接對它進行模式匹配和調用 maybe 函數(shù)使用起來差不多,在不同的場景下,兩種方式都有各自的優(yōu)點。

使用Maybe,以及好的API設計方式?

下面是一個使用 Maybe 的例子。給出一個顧客的名字,找出它們手機號對應的賬單地址。

-- file: ch14/Carrier.hs
import qualified Data.Map as M

type PersonName = String
type PhoneNumber = String
type BillingAddress = String
data MobileCarrier = Honest_Bobs_Phone_Network
                   | Morrisas_Marvelous_Mobiles
                   | Petes_Plutocratic_Phones
                     deriving (Eq, Ord)

findCarrierBillingAddress :: PersonName
                          -> M.Map PersonName PhoneNumber
                          -> M.Map PhoneNumber MobileCarrier
                          -> M.Map MobileCarrier BillingAddress
                          -> Maybe BillingAddress

我們的第一個實現(xiàn)使用 case 表達式,用它完成的代碼相當難看,差不多超出了屏幕的右邊。

-- file: ch14/Carrier.hs
variation1 person phoneMap carrierMap addressMap =
    case M.lookup person phoneMap of
      Nothing -> Nothing
      Just number ->
          case M.lookup number carrierMap of
            Nothing -> Nothing
            Just carrier -> M.lookup carrier addressMap

模塊 Data.Map 的函數(shù) lookup 返回一個 monadic的值:

ghci> :module +Data.Map
ghci> :type Data.Map.lookup
Data.Map.lookup :: (Ord k, Monad m) => k -> Map k a -> m a

換句話說,如果給定的key在map里面存在,那么 lookup 函數(shù)使用 return 把這個值注入到monad里面去;否則就會調用 fail 函數(shù)。這是這個API一個有趣的實現(xiàn),雖然有人覺得它很糟糕。

  • 這樣設計好的一方式是,根據(jù)具體Monad實現(xiàn)的不同,查找成功和失敗的結果是可以根據(jù)不同需求定制的;而且, lookup 函數(shù)本身對于具體的這些行為完全不用關心。
  • 壞處就是,在有些Monad里面調用 fail 會直接拋出讓人惱火的異常;之前我們就警告過最好不要使用 fail 函數(shù),這里就不在贅述了。

實際上,每個人都使用 Maybe 類型作為 lookup 函數(shù)的返回結果;這樣一個簡單的函數(shù)對于它的返回結果提供了它并不需要的通用性:其實 lookup 應該直接返回 Maybe

先放下API設計的問題,我們來處理一下我們之前用 case 編寫的丑陋代碼。

-- file: ch14/Carrier.hs
variation2 person phoneMap carrierMap addressMap = do
  number <- M.lookup person phoneMap
  carrier <- M.lookup number carrierMap
  address <- M.lookup carrier addressMap
  return address

如果這其中的任何一個查找失敗, (>>=)(>>) 的定義告訴我們整個運算的結果將會是 Nothing; 就和我們顯式使用 case 表達式結果一樣。

使用Monad的版本的代碼更加整潔,但是其實 return 是不必要的;從風格上說,使用 return 讓代碼看起來更加有規(guī)律,另外熟悉命令式編程的程序員可能對它感覺更熟悉;但其實上它是多余的;下面是與它等價的版本:

-- file: ch14/Carrier.hs
variation2a person phoneMap carrierMap addressMap = do
  number <- M.lookup person phoneMap
  carrier <- M.lookup number carrierMap
  M.lookup carrier addressMap

List Monad?

Maybe 類型代表有可能有值也可能沒有值的計算;也有的情況下希望計算會返回一系列的結果,顯然,List正適合這個目的。List的類型帶有一個參數(shù),這暗示它有可能能作為一個monad使用;事實上,我們確實能把它當作monad使用。

先不看標準庫的 Prelude 對于List monad的實現(xiàn),我們自己來看看一個 List 的monad應該是什么樣的。這個過程很簡單:首先看 (>>=)return 的類型,然后進行一些替換操作,看看我們能不能使用一些熟悉的list函數(shù)。

return(>>=) 這兩個函數(shù)里面顯然 return 比較簡單。我們已經(jīng)知道 return 函數(shù)接受一個類型,然后把它用類型構造器 m 包裝一下然后產(chǎn)生一個新的類型 m a. 在List這種情況下,這個類型構造器就是 []. 把這個類型構造器使用List的類型構造器替換掉我們就得到了類型 [] a (當然,這樣寫是非法的!);可以把它寫成更加熟悉的形式 [a].

現(xiàn)在我們知道list的 return 函數(shù)的類型應該是 a -> [a] 。對于這種類型的函數(shù),只有少數(shù)那么幾種實現(xiàn)的可能:要么它返回一個空列表,要么返回一個單個元素的列表,或者一個無窮長度的列表?;谖覀儸F(xiàn)在對于Monad的理解,最有可能的實現(xiàn)方式應該是返回單個元素的列表:它不會丟失已有信息,也不會無限重復。

-- file: ch14/ListMonad.hs
returnSingleton :: a -> [a]
returnSingleton x = [x]

如果我們對 (>>=) 的類型簽名進行和 return 類似的替換,我們會得到: [a] -> (a -> [b]) -> [b] . 這看起來和 map 非常相似。

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type map
map :: (a -> b) -> [a] -> [b]

map 函數(shù)的參數(shù)順序和它有點不對應,我們可以改成這樣:

ghci> :type (>>=)
(>>=) :: (Monad m) => m a -> (a -> m b) -> m b
ghci> :type flip map
flip map :: [a] -> (a -> b) -> [b]

但是還是有一點小問題: flip map 的第二個參數(shù)的類型是 a -> b ,但是 (>>=) 的第二個參數(shù)的類型是 a -> [b] ,應該怎么辦呢?

我們對類型進行更多的替換,看看會發(fā)生什么。 flip map 這個函數(shù)能把任何類型 b 作為返回結果;如果我們使用 [b] 來替換 b ,這個函數(shù)的類型就成了 a -> (a -> [n]) -> [[b]]. 換句話說,如果我們使用 map ,將一個列表與一個返回列表的函數(shù)進行映射,我們會得到一個包含列表的列表。

ghci> flip map [1,2,3] (\a -> [a,a+100])
[[1,101],[2,102],[3,103]]

有趣的是,我們這么做并沒有讓 flip map(>>=) 的類型更加匹配一點; (>>=) 的類型是 [a] -> (a -> [b]) -> [b] ;然而,flip map 如果對返回列表的函數(shù)進行map那么它的類型簽名是 [a] -> (a -> [b]) -> [[b]] .在類型上依然是不匹配的,我們僅僅是把不匹配的類型從中間轉移到了末尾。但是,我們的努力并沒有白費:我們現(xiàn)在其實只需要一個能把 [[b]] 轉化成 [b] 的函數(shù)就好了。很明顯 concat 符合我們的要求。

ghci> :type concat
concat :: [[a]] -> [a]

(>>=) 的類型告訴我們應該把 map 的參數(shù)進行翻轉,然后使用 concat 進行處理得到單個列表。

ghci> :type \xs f -> concat (map f xs)
\xs f -> concat (map f xs) :: [a] -> (a -> [a1]) -> [a1]

事實上lists的 (>>=) 定義就是這樣:

-- file: ch14/ListMonad.hs
instance Monad [] where
    return x = [x]
        xs >>= f = concat (map f xs)

它使用函數(shù) f 對列表 xs 的每一個元素 x 進行處理,然后把得到的結果拼接起來得到單個列表。

現(xiàn)在我們已經(jīng)搞定了List這個Monad的兩個核心函數(shù),另外兩個非核心函數(shù)實現(xiàn)起來就很容易了:

-- file: ch14/ListMonad.hs
    xs >> f = concat (map (\_ -> f) xs)
    fail _ = []

理解List monad?

List monad與Haskell的另外一個工具——列表推導非常相似。我們可以通過計算兩個列表的笛卡爾集來說明它們之間的相似性。首先,我們寫一個列表推導:

-- file: ch14/CartesianProduct.hs
comprehensive xs ys = [(x,y) | x <- xs, y <- ys]

這里我們使用大括號語法來表示monadic代碼,這樣會告訴我們monadic代碼和列表推導該有多么相似。

-- file: ch14/CartesianProduct.hs
monadic xs ys = do { x <- xs; y <- ys; return (x,y) }

唯一的一個不同點是使用monadic代碼計算的結果在一系列表達式的末尾得到;而列表推導的結果表示在最開始。除此之外,這個函數(shù)計算的結果是完全相同的。

ghci> comprehensive [1,2] "bar"
[(1,'b'),(1,'a'),(1,'r'),(2,'b'),(2,'a'),(2,'r')]
ghci> comprehensive [1,2] "bar" == monadic [1,2] "bar"
True

一開始肯定對列表monad非常迷惑,我們一起看一下monadic代碼計算笛卡爾集的過程。

-- file: ch14/CartesianProduct.hs
blockyDo xs ys = do
    x <- xs
    y <- ys
    return (x, y)

x 每次取列表 xs 的一個值, y 每次取列表 ys 的一個值,然后組合在一起得到最終結果;事實上,這就是兩層嵌套循環(huán)!這也說明了關于monad的一個很重要的事實:除非你知道m(xù)onad內(nèi)部是如何執(zhí)行的,否則你將無法預期monadic代碼的行為。

我們再進一步觀察這個代碼;首先去掉 do 表示法;稍微改變一下代碼的結構讓它看起來更像一個嵌套循環(huán)。

-- file: ch14/CartesianProduct.hs
blockyPlain xs ys =
    xs >>=
    \x -> ys >>=
    \y -> return (x, y)

blockyPlain_reloaded xs ys =
    concat (map (\x ->
                 concat (map (\y ->
                              return (x, y))
                         ys))
            xs)

如果 xs 的值是 [1, 2, 3] ,那么函數(shù)體的前兩行會依次把x值綁定為 1 , 23 ;如果 ys 的值是 [True, False]; 那么最后一行會被求值六次:一次是 x1 , y 值為 True ;然后是 x 值為 1 , y 的值為 False ;一直繼續(xù)下去。 return 表達式把每個元組包裝成一個單個列表的元素。

使用List Monad?

給定一個整數(shù),找出所有的正整數(shù)對,使得它們兩個積等于這個整數(shù);下面是這個問題的簡單解法:

-- file: ch14/MultiplyTo.hs
guarded :: Bool -> [a] -> [a]
guarded True  xs = xs
guarded False _  = []

multiplyTo :: Int -> [(Int, Int)]
multiplyTo n = do
  x <- [1..n]
  y <- [x..n]
  guarded (x * y == n) $
    return (x, y)

使用 ghci 驗證結果:

ghci> multiplyTo 8
[(1,8),(2,4)]
ghci> multiplyTo 100
[(1,100),(2,50),(4,25),(5,20),(10,10)]
ghci> multiplyTo 891
[(1,891),(3,297),(9,99),(11,81),(27,33)]

還原do的本質?

Haskell的 do 表示法實際上是個語法糖:它給我們提供了一種不使用 (>>=) 和匿名函數(shù)來寫monadic代碼的方式。去除do語法糖的過程就是把它翻譯為 (>>=) 和匿名函數(shù)。

去除do語法糖的規(guī)則非常簡單。我們可以簡單的把編譯器想象為機械重復地對這些do語句塊執(zhí)行這些規(guī)則直到?jīng)]有任何do關鍵字為止。

do 關鍵字后面接單個動作(action)直接翻譯為動作本身。

-- file: ch14/Do.hs
doNotation1 =
    do act
-- file: ch14/Do.hs
translated1 =
    act

do 后面包含多個動作(action)的表示是這樣的:首先是第一個動作,但是接一個 (>>) 操作符,然后一個 do 關鍵字;最后接剩下的動作。當我們對do語句塊重復應用這條規(guī)則的時候,整個do語句快就會被 (>>) 串聯(lián)起來。

-- file: ch14/Do.hs
doNotation2 =
    do act1
       act2
       {- ... etc. -}
       actN
-- file: ch14/Do.hs
translated2 =
    act1 >>
    do act2
       {- ... etc. -}
       actN

finalTranslation2 =
    act1 >>
    act2 >>
    {- ... etc. -}
    actN

<- 標記需

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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號