iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

2
Software Development

mostly:functional 從零開始的異世界程式觀 --- 函數式程式設計的試煉系列 第 34

mostly:functional 終章:Monad 的實體

可是我的心,比整個宇宙,還要大了那麼一點點。

-- 費爾南多‧佩索亞, 詩選:A Little Larger Than the Entire Universe.

過了那座牆,腳下有條筆直的步道,向著偌大區域的中央延展過去。走著走著,視野的旁邊總是會瞥到些閃爍著的什麼,但回過頭去卻又只剩下消失的微微的餘光。

順著路慢慢向前,迎來的是個廣場般的地方,正中間像是噴泉,廣場四周灌木叢若隱若現、外側是一樣黯淡的樹稀疏圍繞,而幽幽的影子如藤蔓般攀附於樹上、悄悄伸展。

當我走到噴泉的前面,原本向下涓流的水改變了角度,在我面前張開一整片的水簾。不管是外觀還是氣氛,都神似之前那些建築裡,最後提出考驗的平台。

流淌的水呼吸般閃爍著微微的光,像是等待著我回應它的提問。

但整個水幕,是一片純然的空白。

所以,那個問題會是什麼?我向遠處望去,透過點滴的水花,看見廣袤的天空,與緩緩飄動的雲。




Monad 可以帶給我們什麼

知道了 Monad 其實只是個能坍縮成一層的容器這件事,似乎比不懂的時候還空虛。當然,這只是個比較好的起點而己。

你有沒有發現,我們一路在 Haskell 裡所示範的函式都很短,沒有超過一行的。回想一下之前在其它的程式語言,例如在 Elixir 裡,我們會寫這樣的函式:

# Elixir 語法
def foo(x) do
  a = [x, x + 1, x + 2]  
  b = a |> Enum.map(fn i -> i * 2 end) # 注意這邊用到了上面的 a
  
  a ++ b ++ [10]                       # 再用到了上面的 a 跟 b
end

foo(1)

但我們到目前為止都沒有看過在 Haskell 裡有類似的寫法:

-- 想像的 Haskell 語法
foo x = 
  a = [x, x + 1, x + 2]
  b = fmap (*2) a         -- 想用上面計算出來的 a
  return a ++ b ++ [10]   -- 再用到上面的 a 跟 b

因為辦不到

在 Haskell 裡,函式的內容必須是一行連續的計算。不是兩行、也不是三行。可以使用之前提過的 pattern matching 拆成多個實作,也可以用一些手段把中間過程暫存起來,但函式的本體,就跟數學的函式一樣,只能是一行連續的計算。

位於純粹的界限之外: IO

而剛剛那個還不是全部,更難搞 (或說純粹,端看你的立場) 的一點,是 Hakell 的函式要將純粹的計算與有副作用的部份區分開來。所謂的副作用是什麼呢?例如說取得使用者輸入輸出到螢幕上亂數與資料庫溝通等等,這種不是純粹的計算,在 Haskell 中,都必須在特別的界限裡處理。而這條界限,就叫 IO。

在其它的語言裡

當我們想要在其它語言,例如 Elixir 中,讀取使用者的輸入,並印到畫面上時,我們可以這樣寫:

# Elixir 語法

IO.gets(:stdin) |> IO.puts

但在 Haskell 中,是沒辦法這樣做的。這件事從函式的型別上就看得出來,讓我們一步一步來看。

-- 想像中的 Haskell 語法 
putStrLn . getLine  -- 會出錯

函式組合的型別

putStrLn 是用來把結果輸出到畫面上的函式,而取得使用者輸入,則是用 getLine 這個函式。讓我們來看一下它們的型別:

-- Haskell 語法 
putStrLn :: String -> IO ()

getLine :: IO String

putStrLn 是個接受一個字串,回傳一個 IO 型別的函式。但 getLine 是回傳一個包在 IO 容器裡的 String 的函式。所以如果像上一小節那樣直接用 . 來函式組合,會因為輸出與輸入的型別不符而編譯失敗。

既然 getLine 會拿到包在 IO 容器裡的字串,而 putStrLn需要的是一個字串,那我們可以用 fmap 嗎?

-- Haskell 語法 
putStrLn <$> getLine :: IO (IO ())

這麼一來是可以編譯成功,但是沒有辦法順利印出來。因為 putStrLn 拿到字串後,會回傳 IO,因此我們拿到的是包在 IO 容器裡的 IO 容器。

這就是我們需要 monad 坍縮性質的時候了。

-- Haskell 語法 
(>>=) :: Monad m => m a -> (a -> m b)  -> m b

(>>=) @ IO ::      IO a -> (a -> IO b) -> IO b

因此寫成這樣才能做到我們想做的事:

-- Haskell 語法
getLine >>= putStrLn

Applicative 辦不到的事

你也許還記得,之前提到 Applicative 時,我們的第一步就是將一個接受多個參數的函式,升格成容器裡的函式。如果我們換一個角度來想,一旦這個計算過程被升格到容器裡後,要怎麼計算接下來接收到的參數這件事就再也沒有變動的空間了。如果我們再一次仔細看 liftA?系列的函式的型別,在接收到第一個函式之後,回傳的就是 f a -> f b -> f c (liftA2),接下來需要幾個參數,每個的型別是什麼,都已經確定下來了。

但是在程式的運作過程中,我們常常會需要依照前面幾步的計算結果不同,來決定接下來要採取哪些計算。而這個就是 Monad 那個坍縮成一層的概念得以發揮的地方。


串列是一種 Monad 嗎?

是的。正如我們之前所示範的,串列是一種 Monad,其 >>= 就是 concat $ map 。不過就跟 functor 的情況一樣,如果你只是想討論串列的話,那可以單純的用 concat $ map (或其它語言的 flat_map ) 就足夠了*。

註*:順帶一提,以 functional reactive 著名的 Rx 框架也是用 flatMap 的概念來談坍縮兩層的 observable 結構為一的。

來舉個能展示 Monad 的用處的例子吧。假如我們想要知道丟兩個骰子所有可能的結果,而且我們把 (1, 2)(2, 1) 視為同一種時,我們可以這樣寫:

-- Haskell 語法
[1..6] >>= \x ->
  [x..6] >>= \y ->  -- 注意這行,我們用了 x..6 排除掉前大後小的結果
    return (x, y)   -- 這裡用 return 函式,將結果裝回串列裡,才會符合型別要求

-- => [(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(2,2),(2,3),(2,4),(2,5),(2,6),(3,3),(3,4),(3,5),(3,6),(4,4),(4,5),(4,6),(5,5),(5,6),(6,6)]

注意在第一次 bind 中傳入的函式 \x -> [x..6] 的部份,我們依賴了之前計算的結果,來進行接下來的計算。而在最後做成元組時,我們用 return 這個函式,將元組再包一層串列的容器外殼。

而這個是串列的 Monad 實作:

-- Haskell 語法
instance Monad [] where
  return = []
  xs >>= f = concat $ map f xs

do 語法糖

在上面的 Monad 示範中,我們用了兩個 >>= 函式來使用 Monad。而在 Haskell 中,有一種稱為 do 語法糖的東西,是讓這種連續的 Monad bind 更容易撰寫,看起來也有一點點像其它程式語言裡的指令式語法 (imperative),但請記得,它其實就是個 Monad:

-- Haskell 語法
rolls :: [(Integer, Integer)]

-- 把這個
rolls = 
  [1..6] >>= \x ->
    [x..6] >>= \y ->
      return (x, y)

-- 改寫成這樣
rolls = do
  x <- [1..6]
  y <- [x..6]
  return (x, y)

這樣子就能看得出來,在 monad 中,把多套上一層容器的殼的函式叫做 return,更加巧妙的讓這種語法寫起來的手感像是其它程式語言的慣例了。

Maybe 是一種 Monad 嗎?

是的。如果是兩層的 Just 的話,那就只留一層,除此之外都是 Nothing

-- Haskell 語法
instance Monad Maybe where  
    return x = Just x  
    Nothing >>= f = Nothing  
    Just x >>= f  = f x  

-- 示範
Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))  -- => Just "3!"

-- do syntax
result = do
  x <- Just 3
  y <- Just "!"
  return (show x ++ y)

二元組是一種 Monad 嗎?

是的。就跟 Applicative 一樣,二元組要成為 Monad 的條件,前面的元素也要是個 Monoid。這麼一來,當兩層的殼坍縮的時候,會用 mappend 將前面的元素併在一起:

-- Haskell 語法
instance Monoid a => Monad ((,) a) where
  return b = (mempty, b)
  (a, c) >>= f = let (b, c') = f c in (a <> b, c')

-- 示範
("hello", 2) >>= \x -> ("world", (x * 10)) -- => ("helloworld", 20)

為什麼只有 Haskell 在講 Monad?

Monad 在 Haskell 這麼受到重視,因為有許多問題的解答,就是利用 Monad 這個坍縮的性質來解決的。而這個性質,有非常多的用法,我們在這裡也僅能列舉一二。除了 IO 之外,還有 State,Reader,以及 Monad Transformer 等等。這些就需要更紮實的去理解 Haskell typeclass 的語法與實作,才有辦法好好的向下探索的。

而之所以其它語言不太談 Monad 這個詞,是因為沒有必要,在其它的語言裡,底下的程式碼是可以依賴上面的程式碼的,副作用也不是嚴格隔離的。在許多語言裡,變數就是可變的,程式的運作是大量依賴於那些變動的狀態在運行的。但是計算與副作用愈混雜,在某些情況下非常方便,但是也會付出另外一些代價,例如稍有不慎,就會寫出狀態與計算糾結在一起的程式碼,導致難以平行化,難以測試與除錯,難以拆解組合修改擴充,為此只好規範出許多的原則、設計模式等等。除此之外,有很多抽象之後,本質上相同的計算,非得要依不同的情況(型別)一次次瑣碎的重新實作......

但這不意味著這些函數式的觀念在其它語言派不上用場。當你可以看到事物的抽象本質時,就能夠在其它的語言裡把純粹計算的部份,與各種不同的副作用的部份各自分離,讓他們用最小耦合的方式待在一起互相合作。而函數式的部份就可以用上各種組抽象與組合的技巧。當然,怎麼跟這個程式語言的天性互補配合,而不是與它博鬥,又是另一個需要拿捏的地方了…




從我開始理解,陣列、 Maybe、IO、甚至函式本身也是一種 Monad 的那一刻起,有一種模糊的感覺出現在我的腦海裡,而我用盡力氣想要理解那究竟是什麼…於是我一次又一次的來回那個地方,探尋,閱讀,試驗,思索,而許多許多時日就那樣一個個流過。

直到那天,我坐在窗邊接著雨開始下了起來。不知道為什麼,我覺得這場雨,是熟悉的,曾經遇過的某一場雨。雖然景物,城市,一切的一切都已經不一樣了,但卻像是有個什麼,能夠跨越漫長漫長的時間,把過往的那個雨天,帶回到我面前……

我忽然意識到,這整座城市,由於其數學上連續計算、不可變動的、隔離副作用的本質,那麼為了要保持一個狀態,其本身就是一個不斷嵌疊的 Monad。

而我,站在這裡的我,只是那個描述整個世界的狀態,型別為 RealWorld,萬千事物層疊的參數裡,非常非常小的一部份而己。

這個驅動世界運作的 Monad,一層接著一層,把整個世界的狀態傳遞給裡面那層的函式,接著進行計算、改變數值、解開外殼、然後再把結果傳遞給下一層的函式…如此不斷反覆,永不停歇,向著時間的盡頭運行…

………我曾經做過的夢,在這座城市裡,是它的現實。

我走回中央的廣場,用手指在水幕上面畫出程式碼……

要開始構造一個這樣的世界,要從把多個函式 >>= 在一起開始,然後讓一切運行起來的那個參數是……

隨著字一個個畫下,雨漸漸、漸漸的停了。天光如簾幕般柔和的、緩慢的降臨到這座城市裡,而萬物的色彩開始回歸。身旁原本透明的建築上開始渲染出顏色跟質感跟光與水的折射,一座座質樸學院樣貌的建築錯落開來。而在城市中央的區域裡,壯麗的塔樓與教堂直入雲霧…


「嘿。」

「蠻厲害的嘛。」

專注中、我聽到一個很久很久沒有聽到,但卻非常熟悉的聲音。

「red... red panda?」

我轉過頭去。

花園旁的矮柱上,我瞥見了棕紅色的毛,短短的胖爪子,膨膨的尾巴,那是過了許久許久,在記憶裡已快要褪去的身影。

還有那個帶著白線的眼角,跟總像是在思索著什麼的表情…


「你這次叫對了呢。」

~[FIN]~


上一篇
mostly:functional 第二十九章:Monad 的法則
下一篇
mostly:functional 謝幕與片尾曲
系列文
mostly:functional 從零開始的異世界程式觀 --- 函數式程式設計的試煉35

尚未有邦友留言

立即登入留言