Haskell學習-monad

Jeff.Zhong發表於2018-08-17

原文地址:Haskell學習-monad

什麼是Monad

Haskell是一門純函式式的語言,純函式的優點是安全可靠。函式輸出完全取決於輸入,不存在任何隱式依賴,它的存在如同數學公式般完美無缺。可是純函式因為隔絕了外部環境,連最基本的輸入輸出都無法完成。而 Monad 就是 Haskell 給出的解決方案。但Monad 並不僅僅是 IO 操作的抽象,它更是多種類似操作之間共性的抽象。所以 Monad 解決的問題並不侷限在 IO 上,像 Haskell 中的 Maybe[] 都是 Monad。Haskell 中漂亮的錯誤處理方式, do 表示法和靈活的列表推導式 (list comprehension) 都算是 Monad 的貢獻。

Monad 基本上是一種加強版的 Applicative Functor,正如 Applicative FunctorFunctor 的加強版一樣。所以在充分理解 Applicative Functor 的基礎上,過渡到 Monad 其實是非常平滑的。

-- Monad的定義
class Monad m where
    return :: a -> m a
    (>>=) :: m a -> (a -> m b) -> m b
    (>>) :: m a -> m b -> m b
    x >> y = x >>= \_ -> y
    fail :: String -> m a
    fail msg = error msg
  • return 跟其他語言中的 return 是完全不一樣的,它是一個把普通值包進一個 context 裡面的函式,並不是結束函式執行的關鍵字。其實等價於Applicative中的 pure
  • >> 忽略前面表示式的返回值,直接執行當前表示式。
  • >>= 接受一個 monadic value(也就是具有 context 的值,可以用裝有普通值的盒子來比喻)並且把它餵給一個接受普通值的函式,並回傳一個 monadic value。
  • =<< 和上面 >>= 功能一樣,只是結合順序相反。

Monad 的原理

函式之間要協作,就必須以各種形式互動連線。但如何隔離純函式與副作用函式,同時又能讓兩類函式相互複用呢?

以 IO 操作為例子分析,為了充分隔離純函式與 IO 函式,Haskell 中不能實現 IO Char -> Char 這樣一種輸入是 IO 型別返回值卻是普通型別的函式。否則副作用函式就能很容易變身為純函式了。事實上一旦引數中有 IO,返回值必有 IO,這就保證了充分隔離。

那如何讓純函式與 IO 函式相互複用呢?這就要靠 IO Monad 中定義的 return>>= 這兩個函式了。return (在 Haskell 中不是關鍵字,只是一個函式名)的作用是將某個型別為 A 的值 a 提升(裝箱)為型別為 IO A 的值 Char -> IO Char 。有了這個函式後,純函式就可以通過 return 變成返回值為 IO 帶副作用的函式了。

有了提升而沒有下降操作,怎麼複合 putChar :: Char -> IO()getChar :: IO Char 呢。 getChar 從 IO 讀取一個字元, putChar 把字元寫入 IO。但 getChar 返回的是 IO Char 型別,而 putChar 需要的是普通的 Char 型別,兩者不匹配怎麼辦? >>= 函式出馬了! >>= 的型別是

IO a -> (a -> IO b) -> IO b

這樣 >>= 就可以連線 getCharputChar ,把輸入寫到輸出中

getChar >>= putChar

可以看到 >>= 操作實際上是型別下降(或拆箱)操作,同時執行下降操作的函式返回值也必須是 IO 型別。這樣既充分隔離純函式與副作用函式,又能讓函式相互複用。通過 return>>= 兩個平行世界 (範疇) 就有了可控的交流通道。

do 表示法

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

do 表示法可以使用分號 ; 和大括號 { } 將語句分塊;但一般會使用一個表示式一行的方式,不同的作用域用不同的縮排區分。

我們還是以IO 為例子,接受兩次的鍵盤輸入,然後將兩次輸入的字串合併成一個字串,最後螢幕列印輸出。 >>= 會接受前面表示式的值;>> 則會忽略前面表示式的值;這裡使用 return 實際它返回的仍然是IO String,因為Haskell會自動型別推導得出。monadic 的表示式程式碼如下:

(++) <$> getLine <*> getLine >>= print >> return "over"
111
222
> "111222"
> "over"

使用 do改寫,明顯更加清晰,和我們熟悉的命令式語言風格差不多。
<- 表示從monadic value中取出普通值,可以看成是拆開盒子取出所需要的值。

foo :: IO String  
foo = do  
    x <- getLine
    y <- getLine
    print (x ++ y)
    return "over"

do語法對應模式

do {e}             -> e
do {e; es}         -> e >> do {es}
do {let decls; es} -> let decls in do {es}
do {p <- e; es}    -> e >>= \p -> es

Monad 型別

來看一下幾個預設的Monad型別,它們都必須實現 return,>>=,fail這幾個函式。

  1. Maybe
    中間任何一步只要有Nothing,結果就提前返回Nothing。沒有任何意外的情況才返回Just 值

    -- Maybe 的 Monad instance
    instance Monad Maybe where
        return x = Just x
        Nothing >>= f = Nothing
        Just x >>= f  = f x
        fail _ = Nothing
    
    -- 例項
    Just 3 >>= (\x -> Nothing >>= (\y -> Just (show x ++ y)))
    > Nothing
    
    Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))
    > Just "3!"

    使用 do 表示法寫成這樣:

    foo :: Maybe String
    foo = do
        x <- Just 3
        y <- Just "!"
        Just (show x ++ y)
  2. List
    >>= 基本上就是接受一個有 context 的值,把他喂進一個只接受普通值的函式,並回傳一個具有 context 的值。[ ] 其實等價於 Nothing。

    當我們用 >>= 把一個 list 餵給這個函式,lambda 會對映每個元素,會計算出一串包含一堆 list 的 list,最後再把這些 list 壓扁,得到一層的 list。這就是我們得到 列表 list 處理 Mondic value 的過程。

    --list 的 Monad instance
    instance Monad [] where
        return x = [x]
        xs >>= f = concat (map f xs)
        fail _ = []
    
    -- 例項
    [3,4,5] >>= \x -> [x,-x]
    > [3,-3,4,-4,5,-5]
    
    [1,2,3] >>= \x -> return (-x)
    > [-1,-2,-3]

    list comprehension 也不過是 Monad 的語法糖

    [1,2] >>= \n -> ['a','b'] >>= \ch -> return (n,ch) -- Monad
    [ (n,ch) | n <- [1,2], ch <- ['a','b'] ] -- list comprehension
    > [(1,'a'),(1,'b'),(2,'a'),(2,'b')]

    list comprehension 的過濾基本上跟 guard 是一致的。

    [1..50] >>= (\x -> guard ('7' `elem` show x) >> return x)
    > [7,17,27,37,47]

    do 改寫, 如果不寫最後一行 return x,那整個 list 就會是包含一堆空 tuple 的 list。

    sevensOnly :: [Int]
    sevensOnly = do
        x <- [1..50]
        guard ('7' `elem` show x)
        return x
    
    -- 對應的 list comprehension
    [ x | x <- [1..50], '7' `elem` show x ]
    > [7,17,27,37,47]
  3. Either
    Control.Monad.Error 裡面有 ErrorMonad instance

    instance (Error e) => Monad (Either e) where
        return x = Right x
        Right x >>= f = f x
        Left err >>= f = Left err
        fail msg = Left (strMsg msg)
    
    Right 3 >>= \x -> return (x + 100) :: Either String Int
    > Right 103

Monad 規則

  1. return a >>= f == f a
    == 左邊的表示式等價於右邊的表示式。如果僅僅是把一個值包裝到monad裡面然後使用 (>>=) 呼叫的話,我們就沒有必要使用 return ;這條規則對於我們的程式碼風格有著實際的指導意義:我們不應該寫一些不必要的程式碼;這條規則保證了簡短的寫法和冗餘的寫法是等價的。

    return 3 >>= (\x -> Just (x+100000)) -- 和直接函式呼叫沒有區別
  2. m >>= return == m
    這一條規則對風格也有好處:如果在一系列的action塊裡面,如果最後一句就是需要返回的正確結果,那麼就不需要使用 return 了;和第一條規則一樣,這條規律也能幫助我們簡化程式碼。

     Just "move on up" >>= return -- 可以不需要 return
  3. (m >>= f) >>= g == m >>= (\x -> f x >>= g)
    當我們用 >>= 把一串 monadic function 串在一起,他們的先後順序不應該影響結果。
    而這不就是結合律嗎?我們可以把那些子action提取出來組合成一個新action。
    (<=<) 可以用來合成兩個 monadic functions, 類似於普通函式結合(.), 而(>=>) 表示結合順序相反。

    (<=<) :: (Monad m) => (b -> m c) -> (a -> m b) -> (a -> m c)
    f <=< g = (\x -> g x >>= f)
    
    -- 普通函式結合(.)
    let f = (+1) . (*100)
    f 4
    > 401
    
    -- 合成monadic functions (<=<)
    let g = (\x -> return (x+1)) <=< (\x -> return (x*100))
    Just 4 >>= g
    > Just 401
    
    -- 也可以將 monadic 函式用foldr,id 和(.)合成 
    let f = foldr (.) id [(+1),(*100),(+1)]
    f 1
    > 201

Monad 的 (->) r 形態

(->) r 不只是一個 functorapplicative functor,同時也是一個 monad

每一個 monad 都是個 applicative functor,而每一個 applicative functor也都是一個 functor。儘管 moandfunctorapplicative functor 的性質,但他們不見得有 FunctorApplicative 的 instance 定義。

instance Monad ((->) r) where
    return x = \_ -> x
    h >>= f = \w -> f (h w) w

Monad 輔助函式

帶下劃線函式等價於不帶下劃線的函式, 只是不返回值

>>= :: m a -> (a -> m b) -> m b
=<< :: (a -> m b) -> m a -> m b
form :: t a -> (a -> m b) -> m (t b)
form_ :: t a -> (a -> m b) -> m ()
mapM :: (a -> m b) -> t a -> m (t b)
mapM_ :: (a -> m b) -> t a -> m ()
filterM :: (a -> m Bool) -> [a] -> m [a]
foldM :: (b -> a -> m b) -> b -> t a -> m b
sequence :: t (m a) -> m (t a)
sequence_ :: t (m a) -> m ()
liftM :: (a1 -> r) -> m a1 -> m r
when :: Bool -> f () -> f ()
join :: m (m a) -> m a

其中在 IO 中經常用到的一些函式

  1. sequence
    sequence 接受一串 I/O action,並回傳一個會依序執行他們的 I/O action。運算的結果是包在一個 I/O action 的一連串 I/O action 的運算結果。

    main = do
        a <- getLine
        b <- getLine
        c <- getLine
        print [a,b,c]

    其實可以寫成

    main = do
        rs <- sequence [getLine, getLine, getLine]
        print rs

    一個常見的使用方式是我們將 printputStrLn 之類的函式 map 到串列上。

    sequence (map print [1,2,3,4,5])
    1
    2
    3
    4
    5
    [(),(),(),(),()]
  2. mapMmapM_
    由於對一個串列 map 一個回傳 I/O action 的函式,然後再 sequence 這個動作太常用了。所以函式庫中提供了 mapMmapM_mapM 接受一個函式跟一個串列,將對串列用函式 map 然後 sequence 結果。mapM_ 也作同樣的事,只是他把運算的結果丟掉而已。在我們不關心 I/O action 結果的情況下,mapM_ 是最常被使用的。

    mapM print [1,2,3]
    1
    2
    3
    [(),(),()]
    
    mapM_ print [1,2,3]
    1
    2
    3

    formform_mapMmapM_ 類似,不過只是把列表引數提前。

還有一些是在 monad 中定義,且等價於 functorapplicative functor 中所具有的函式。

  1. liftM
    liftMfmap 等價, 也有 liftM3liftM4liftM5

    liftM :: (Monad m) => (a -> b) -> m a -> m b
    liftM f m = m >>= (\x -> return (f x))
    
    liftM (*2) [1,2]
    > [2,4]
  2. ap
    ap 基本上就是 <*>,只是他限制在 Monad 上而不是 Applicative 上。

    ap :: (Monad m) => m (a -> b) -> m a -> m b
    ap mf m = do
        f <- mf
        x <- m
        return (f x)
    
    ap [(*2)] [1,2,3]
    > [2,4,6]
  3. join
    m >>= f 永遠等價於 join (fmap f m) 這性質非常有用

    join :: (Monad m) => m (m a) -> m a
    
    join (Just (Just 9))
    > Just 9
    
    join [[1,2,3],[4,5,6]]  -- 對於 list 而言 join 不過就是 concat
    > [1,2,3,4,5,6]
  4. filterM
    filterM,除了能做 filter 的動作,同時還能保有 monadic context。

    filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
    
    filterM (\x -> return (x > 2)) [1,2,3,4]
    > [3,4]
  5. foldM
    foldl 的 monadic 的版本叫做 foldM

    foldM :: (Monad m) => (a -> b -> m a) -> a -> [b] -> m a
    
    foldM (\x y -> return (x + y)) 0 [1,2,3]
    > 6

相關文章