解決巢狀運算的 Monad
February 12, 2022假設現在有 findOrder
、findCustomer
、findAddress
等函式:
findOrder :: String -> Maybe Order
findOrder number = -- 一些程式碼
findCustomer :: Order -> Maybe Customer
findCustomer order = -- 一些程式碼
findAddress :: Customer -> Maybe Address
findCustomer customer = -- 一些程式碼
這三個函式分別代表,可以從訂單號碼查詢訂單資訊(Order
),從訂單資訊中查詢客戶資訊(Customer
),從客戶資訊中查詢位址資訊(Address
),函式的傳回值都是 Maybe Something
,表示可能有也可能沒有結果。
如果有個訂單號碼,想要一路查出位址位址資訊,會怎麼寫呢?
address =
case findOrder "X1234" of
Nothing -> Nothing
Just order -> case findCustomer order of
Nothing -> Nothing
Just customer -> findAddress customer
重複、難以閱讀等問題顯而易見,出現了巢狀的運算,如果想一路查找出更多資訊,情況就會更糟,你可能會想到,在取得 Maybe Order
之後,接下來是取得 Maybe Customer
,然後是取得 Maybe Address
,這讓我們回想起 Functor
,fmap
可以解決這個問題嗎?
fmap
的型態是 (a -> b) -> f a -> f b
,因為 findOrder
、findCustomer
、findAddress
等函式的型態,都是 a -> Maybe b
,沒辦法直接將 findOrder
、findCustomer
、findAddress
直接當作 fmap
的第一個引數,就算勉強寫出了以下的程式,Callback hell 只會令情況更糟:
address = case fmap (\order ->
fmap (\customer -> findAddress customer) (findCustomer order))
(findOrder "X1234") of Nothing -> Nothing
Just (Just (Just addr)) -> Just addr
Maybe 的 Monad 行為
不過,fmap
給了點啟發,需要類似的版本,然而要能直接接受 a -> Maybe b
函式,仔細觀察一開始判斷值存在與否的巢狀運算,每一層都是這樣的:
case mapper something1 of
Nothing -> Nothing
Just something2 -> case mapper something2 of -- 重複的結構
可以寫個 flatMap
函式,讓 mapper
當做引數傳入:
flatMap :: Maybe a -> (a -> Maybe b) -> Maybe b
flatMap Nothing _ = Nothing
flatMap (Just something) mapper = mapper something
這麼一來,原先的 address
就可以寫成:
address = findOrder "X1234" `flatMap` findCustomer `flatMap` findAddress
取名為 flatMap 是因為,Maybe a
會被打平為 a
,再套用 a -> Maybe b
進行映射;實際上像 flatMap :: Maybe a -> (a -> Maybe b) -> Maybe b
這樣的行為,Haskell 在 Control.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
與 >>=
函式,從 >>=
函式型態可以看出來,這是比上頭 flatMap
更通用的型態,return
用來將指定的值,放到環境 m
之中,像是將值放入 Maybe
之中,就 Monad
來說,因為 >>=
要處理 m a
,如果有個 a
,自然就需要用 return
來得到 m a
!
你也許猜到了,Maybe
就實現了 Monad
的行為:
instance Monad Maybe where
return x = Just x
Nothing >>= _ = Nothing
(Just something) >>= f = f something
因此方才的需求,可以直接如下撰寫:
address = findOrder "X1234" >>= findCustomer >>= findAddress
你可以一路進行下去,解決掉原先會形成巢狀運算的問題,閱讀起來也很輕鬆,就是找出訂單、找出客戶、找出位址,有結果的就是 Just something
,沒結果的話就是 Nothing
。
List 的 Monad 行為
現在來看另一個需求,如果有一串訂單(Order
),每個訂單上有項目(Item
),你想取得全部訂單上全部項目,那麼可以先使用 fmap findItems orders
,其中 findItems
是型態 Order -> [Item]
的函式,因此 fmap findItems orders
會得到 [[Item]]
,最後再使用 concat
將元素串起來成為 [Item]
,就可以得到結果。
如果要進一步使用 findPremiums :: Item -> [Premium]
從 [Item]
取得每個項目的贈品(Premium
)清單呢?那就是 concat (fmap findPremiums items)
啦!顯然地,出現重複的結構了。
仔細觀察 findItems
與 findPremiums
,一個是 Order -> [Item]
,一個是 Item -> [Premium]
,嗯?a -> [b]
?這不就是 a -> m b
的模式嗎?那麼,list 是個 Monad
嗎?確實是的:
instance Monad [] where
return x = [x]
xs >>= f = concat (map f xs)
fail _ = []
因此對於以上需求,如果想從一串訂單查得一串贈品,可以直接使用 orders >>= findItems >>= findPremiums
,最後得到一個 [Item]
。
IO 的 Monad 行為
回顧一下〈Hello, Haskell〉,重新寫個「哈囉!世界!」:
main = do
name <- getLine
putStrLn ("哈囉, " ++ name)
getLine
的傳回型態是 IO String
,你取得其中的 String
,然後執行 putStrLn
,得到一個 IO ()
,也就是說你做了一個從 String -> IO ()
的動作,將一開始 IO String
對應至 IO ()
,那麼,IO
是個 Monad
嗎?是的!上面的程式也可以這麼寫:
sayHello :: String -> IO ()
sayHello name = putStrLn ("Hello, " ++ name)
main = getLine >>= sayHello
為了與 do
的寫法對應,將 sayHello
換為 lambda 函式並略為排版:
main = (
getLine >>= (\name ->
putStrLn ("Hello, " ++ name)))
括號只是便於識別 lambda 函式罷了,來去掉括號並略做排版:
main =
getLine >>= \name ->
putStrLn ("Hello, " ++ name)
這是一個不使用 do
的版本,記得嗎?〈初探 IO 型態〉中談到「可以先將 do
理解為,逐層銜接一組 IO
,do
最後呼叫函式的傳回型態,就是 do
的傳回型態」,現在可以更具體地說明了,在具有副作用的函式中,do
相當於逐層地使用 >>=
及 lambda 函式,將 IO
銜接起來,最後的傳回型態,取決於最內層 lambda 函式傳回的 IO
型態。
實際上,do
就只是讓你不用寫一堆 >>=
與 lambda 函式罷了,那麼 do
看來不是只能作用在 IO
,而是可以作用在 Monad
上囉?是的!並不是使用了 do
與 <-
令函式成為不純綷的函式,而是使用了 IO
這個特別的 Monad
,才讓函式成為不純綷的函式,下一個主題中,我們會看到 do
可以作用在 Maybe
、list 等 Monad
,當然,與之搭配的 <-
也是!
那麼為什麼要 IO
成為 Monad
的實例呢?
如果函式的型態傳回了 IO
,代表「你使用了狀態不歸自己管的東西」,可以將那東西看成是包含了你無法掌握狀態的運算情境,封裝運算情境正是 Monad
之目的,而需求是想從無法掌握狀態的運算情境取得結果,。
而且 Haskell 特別為 IO
加上了限制,如果函式使用了傳回 IO
的函式,該函式也必須傳回 IO
,也就是函式中若呼叫了不純綷的函式,該函式也會是個不純綷的函式,藉此將純與不純分開。
Monad 定律
最後,如同 Functor
與 Applicative
在實作時,都有其要遵守的定律,Monad
也有其要遵守的定律,有機會的話可以思考看看,遵守以下這些定律的意義:
return a >>= k == k a
m >>= return == m
m >>= (\x -> k x >>= h) == (m >>= k) >>= h