仙境里的Haskell(之七)—— IO Monad

Monad是這樣一種東西:當你理解了它以後,你就會不理解自己以前為什麼不理解它。

在在What I wish I knew When Learning Haskell中,作者建議:

  1. 不要讀monad教程
  2. 不要寫用類比的方式講解Monad的教程。

In other words, the only path to understanding monads is to read the fine source, fire up GHC, and write some code. Analogies and metaphors will not lead to understanding.

我在仙境里的Haskell(之五)里寫的獲取用戶、獲取郵箱的代碼例子對於理解Monad是非常重要的。

它展示了Monad的一個重要方面——把Monad a和 a -> Monad b和b -> Monad c通過>>=進行組合,而組合的策略是通過>>=自定義的。

在Monad Maybe的實現中,>>=的策略是:如果組合中的一個值是None,則後續的計算不再進行,整個組合後的結果都是None了。如果這個邏輯沒有被提煉在Maybe的>>=操作里,那麼當你組合Maybe a和a -> Maybe b和b -> Maybe c的時候,你就要重複的這個判斷。 建議讀者自己去敲代碼感受一下。

因此Monad的價值之一是可自定義的組合策略

Monad的第二個核心價值體現在Monad的另一個基本操作上:

return :: a -> m an

這個函數名字return和return語句沒有任何關係,它的作用是把一個普通的值a「放到」Monad裡面。至於為什麼叫這個令人困擾的名字可能是個歷史遺留問題了。

結合>>=操作的函數簽名:

(>>=) :: m a -> (a -> m b) -> m bn

你會發現,Monad沒有提供把值從Monad裡面拿出來的操作。 一個普通的值進入Monad裡面後,它就再也出不來了。。。所謂:

一入宮門深似海 從此高牆絕紅塵

很容易猜到,List的return實現就是[],Maybe的return實現就是Just,而本文的主角IO的return實現叫做returnIO,不過大家一般都直接用return。

(Maybe和List提供了把值拿出來的方法,但那是它們自身類型提供的,而不是Monad定義的)。

來看兩個IO函數:

getLine :: IO Stringn nputStrLn :: String -> IO ()n

getLine讀入一行用戶的輸入,得到一個String。注意不是原生String而是IO String, 代表這是執行了一個IO操作後的結果。對獲取的String進行操作的唯一方法是通過>>=,而結果仍然是IO a,這樣一來IO就像標籤一樣把有副作用的函數標示了出來。

putStrLn把字元串輸出到屏幕上,返回結果IO ()。 ()是個類型,讀做Unit,表示這個返回值沒有意義,可以忽略,類似於void。函數的意義在於調用過程中產生的副作用(比如列印到屏幕)。

module Main wheren nimport Data.Char (toUpper)n nmain :: IO ()nmain = getMonadPutnn ngetMonadPut :: IO ()ngetMonadPut = putStrLn "input something" >>= (_ -> getLine) >>= putStrLn . toUpperCasen ntoUpperCase :: String -> StringntoUpperCase = map toUppern

注意toUpperCase是個純函數,而getMonadPut是IO ()。IO操作絕對無法污染純函數

m a >>= (_ -> f) 這種模式非常常見,代表前一步運算的結果不重要,直接忽略,接著做後面的計算。因為非常常見,所以標準庫里提供了一個函數:

(>>) :: m a -> m b -> mbnm >> k = m >>= _ -> kn

所以可以簡化一下程序:

getMonadPut :: IO ()ngetMonadPut = putStrLn "input something" >> getLine >>= putStrLn . toUpperCasen

封裝性已經體現無疑了,那麼IO的>>=的組合策略是什麼呢?那就是如果某一步IO操作出錯拋了IO異常,則後續的操作都不繼續了。你可以想想如果沒有這個策略,那你每一步其實都要try catch。

上面的代碼很Monad,但是即使對於函數式編程高手來說可讀性也不是很好。 因此Haskell提供了do操作符。

getDoPut :: IO ()ngetDoPut = don putStrLn "input something"n line <- getLinen let output = toUpperCase linen putStrLn outputn

do是個語法糖,這段代碼和上面的Monad版本是等價的。 如何把do代碼塊展開成>>=版本是必須要掌握的。

do適用於所有的Monad,並不是僅限於IO,因此我在仙境里的Haskell(之五)里寫的獲取用戶、獲取郵箱的代碼例子可以用do操作符改寫:

fetchEmail :: UrlStr -> Maybe EmailnfetchEmail urlStr = don conn <- getConn urlStrn user <- getUser connn email <- getEmail usern return emailn

Monad的概念來自範疇論。在範疇論里一個Monad必須遵循三個法則:

  1. (return x) >>= f == f x
  2. m >>= return == m
  3. (m >>= f) >>= g == m >>= (x -> f x >>= g)

範疇論(犯愁論?)的內容等我以後寫《魔界里的Haskell》的時候再說吧,現在大家只要記住,如果你要自定義一個Monad的話,請盡量保證符合這三個法則。否則用do的時候可能會出現一些微妙的錯誤。

從目前介紹了的Maybe、List和IO三個Monad,你是否能發現一種模式呢? 你是否能想到用Monad模式解決一些別的實用問題呢?


推薦閱讀:

Algebra of Programming - initial algebra

TAG:Haskell | 函数式编程 |