對于 Haskell 初學者來說,Monad 和 IO 或許是掌握 Haskell 之路上的第一大難關。本文将會以盡量淺顯的方式介紹 Monad 和 IO 背後的原理和設計思想,希望能夠給 Haskell 初學者們一些思考與啟發。
本文假設您對函數式程式設計有一定的了解,因為這是讨論 Monad 和 IO 的理論基礎。同時,本文會使用到一些 Haskell 基礎文法,比如函數類型定義等。
本文需要您對偏函數和柯裡化(currying)有所了解。如果您對此不了解,您可以參考網絡上的其它文章。為了減輕讀者的了解負擔,本文僅在必要時采用柯裡化的函數定義形式。
前言
我們知道,純函數式程式設計中的函數必須是無副作用的。也就是說,這些函數 不能擁有狀态,也 不能改變外界的狀态(比如使用全局變量、在螢幕上輸出資訊),并且每一個輸入必須 唯一對應一個輸出。
但是,現實中的程式幾乎都需要進行一些有副作用的操作。比如,一個程式至少應該能夠輸出一些資訊,或者寫入一些檔案等,否則這個程式将會毫無意義。是以,即使是函數式程式設計語言,也有必要引入有副作用的操作。
在 Haskell 中,與副作用有關的兩個最重要的概念是 Monad 和 IO。本文将由淺入深地介紹這兩個概念。
本文部分譯自 Noel Winstanley 的文章 What the hell are Monads?。如果您對本文内容有不同見解,歡迎您對本文提出改進意見。
狀态的引入
既然函數必須是無狀态的,那麼我們不妨先設法引入狀态,作為讨論 IO 的一個開始。
假設我們用 Haskell 編寫了一個資料庫系統,并且提供一個
update
函數用于更新資料庫内的記錄。
update
函數的定義如下:
update :: (DB, Int) -> Bool
update
函數向資料庫中寫入一個
Int
值,并傳回一個
Bool
值表示操作是否成功。
但這樣的定義是存在問題的,因為
update
函數必然會改變
DB
的狀态,而根據函數式程式設計的基本規則,參數
DB
是不可變的,
update
函數無法修改
DB
的狀态。
解決方法也很簡單,既然
update
會改變
DB
的狀态,那麼我們不妨讓
update
函數除了傳回一個
Bool
值外,還傳回一個 更新了狀态之後的
DB
,如下:
update :: (DB, Int) -> (DB, Bool)
經過這樣的修改,我們實際上就已經能夠讓
DB
對象擁有 内部狀态 了。(外部狀态 稍微複雜一些,這個問題将會在之後讨論。)但這樣的實作方式會引起一個附加問題:它讓 不同操作之間的組合 變得複雜了。假如我們還定義了一個
query
函數,如下:
query :: DB -> (DB, Int)
然後,我們的主程式需要做如下的事情:在資料庫中查詢一個值 x x x,然後把 x + 1 x + 1 x+1 寫回資料庫中。那麼,我們的主程式就需要這樣編寫:
(x, db1) = query db
(ok, db2) = update (db1, (x + 1))
問題就在于,我們不能直接把
query
的傳回值加 1 1 1 然後傳給
update
,因為
query
函數除了傳回 x x x 之外,還會傳回一個額外的東西
DB
,它對于程式本身意義不大,但又不得不保留下來并傳給之後的操作使用。這會增加程式的複雜度,增大代碼閱讀和維護的負擔。
而 Monad 實際上是一種類型層面的文法糖,用來降低對這種「有狀态的純函數」的串聯工作的複雜度。
狀态轉移操作的串聯
注:以下兩節的内容較為數學化,如果您有看不懂的地方,可以直接跳到之後的【外部狀态的管理】一節,這不影響之後對外部狀态和 IO 對象的讨論。
在介紹 Monad 之前,我們不妨先從簡單的情況入手。我們想到,
update
函數實際上表達了一種 狀态轉移操作。為了對這樣的操作進行模組化和簡化,我們首先需要将
update
函數進行柯裡化,如下:
update :: Int -> DB -> (DB, Bool)
加括号之後:
update :: Int -> (DB -> (DB, Bool))
然後我們發現,其中的
(DB -> DB Bool)
可以抽象成一類函數:
type StateTransDB a = DB -> (DB, a)
一個
StateTransDB
類型的函數實際上代表了一種 狀态轉移操作,它接受一個舊狀态
DB
,傳回一個結果 a 以及一個新狀态
DB
。
有了狀态轉移操作,我們就可以通過一個函數來表示狀态轉移操作之間的連接配接:
dbThen :: StateTransDB a -> (a -> StateTransDB b) -> StateTransDB b
這個函數的意思是:給定了 前一個操作
StateTransDB a
和一個 中間函數
f :: (a -> StateTransDB b)
,這個函數
f
接受 前一個操作的傳回值 a,傳回 第二個操作
StateTransDB b
。然後
dbThen
便會傳回 這兩個操作的串聯操作。比如下面的函數:
queryAndUpdate :: DB -> (DB, Int)
queryAndUpdate db = query db `dbThen` \x -> update (x + 1)
表示的意思就是:将第一個操作
query
和第二個操作
update
串聯起來,串聯的規則是:對于
query
傳回的結果 x x x,調用函數
\x -> update (x + 1)
執行 u p d a t e ( x + 1 ) update(x + 1) update(x+1)。
queryAndUpdate
這一個操作實際上是兩個操作的複合操作,我們隻需要調用
queryAndUpdate db
,就能夠一次執行這兩個操作,并獲得最終的
Bool
結果和更新後的
DB
。
Monad
以上的類型設計實際上具有通用性。為了友善地表達這一類操作,Haskell 中引入了 Monad 的概念。Monad 本身并不能實作有狀态化或有副作用化,它隻是一種特殊的類型,能夠在需要進行 狀态轉移操作的串聯 時,充當文法糖的作用。
一個簡單的 Monad 類型可以按如下方式定義:
class Monad m where
>>= :: m a -> (a -> m b) -> m b
>> :: m a -> m b -> m b
return :: a -> m a
m >> k = m >>= \_ -> k
這個定義十分複雜,尤其其中有 4 個未知類型 a,b,m,k,很難從類型本身推斷出其用途。
仍然以上面的資料庫系統為例,資料庫系統實際上可以表示成一個
Monad StateTransDB
。那麼,其中最重要的
>>=
操作的類型就是:
>>= :: StateTransDB a -> (a -> StateTransDB b) -> StateTransDB b
這實際上就是我們在上面定義的
dbThen
函數。有了 Monad 之後,我們的
queryAndUpdate
函數就可以寫成如下形式:
queryAndUpdate db = query db >>= (\x -> update (x + 1))
下面我們就可以繼續解釋剩下的兩種操作(
>>
和
return
)了:
>> :: StateTransDB a -> (StateTransDB b -> StateTransDB b)
StateTransDB >> k = StateTransDB >>= \_ -> k
return :: a -> StateTransDB a
>>
代表着忽略前一個操作的傳回值。比如:
update (db, 42) >> query
這個操作會首先向資料庫中寫入 42,忽略傳回的
Bool
值,然後再查詢資料庫,傳回結果。
return
會傳回一個 空的狀态轉移操作,這個操作傳回給定的值。比如執行以下代碼:
ret42 = return 42
(db1, x) = ret42 db
之後,
x = 42
,并且
db1
和
db
的狀态應當是相同的。
要對
StateTransDB
實作 Monad,我們隻需要:
instance Monad StateTransDB where
(>>=) = dbThen
return a = (\db -> (db, a))
外部狀态的管理
下面我們讨論對外部狀态的管理。在我們的資料庫系統的例子中,我們隻實作了内部狀态。但實際的程式中難免要操作外部狀态,比如磁盤上的檔案,螢幕上的顯示,網絡套接字等。我們不妨假設 整個外部狀态 儲存在一種叫
World
的類型中。
外部狀态相對于内部狀态來說,具有不可拷貝性和不可回溯性。也就是說,我們不可能把
World
儲存到一個常量中,然後用同一個
World
分别調用兩個不同的操作,并觀察在兩條不同的世界線中所發生的事情,這是違背客觀規律的。
上文中,我們的「有狀态的純函數」設計顯然不能滿足這兩個限制條件。隻要我們能夠讀取某個操作的傳回值,我們就能夠對
World
對象進行拷貝,進而破壞這兩條限制。
不同的函數式程式設計語言對這個問題有不同的解決方案,如 Clean 語言通過擴充類型系統來限制
World
對象隻能使用一次,稱為 uniqueness type。Haskell 中使用了一種叫
IO
的特殊類型來防止使用者程式擷取和拷貝
World
對象,并且使得
World
對象永遠隻能單向流動。
IO 對象
在 Haskell 中,一個 IO 對象封裝了 某種帶有副作用的操作(比如 I/O 操作)。比如
putStrLn "Hello world!"
會 傳回 一個 IO 對象,這個 IO 對象 代表着 輸出
Hello world!
這個操作。借助 Monad,可以将多個 IO 對象串聯成一個 IO 對象,實作 I/O 操作的順序執行。
以上的描述也許聽起來比較玄乎,但我們隻要看看 IO 對象的定義就明白了:
type IO a = World -> (World, a)
也就是說,每個 IO 對象實際上是一個函數,這個函數的參數是
World
對象,傳回值是 a 和一個修改過後的
World
對象。由于函數的參數中有
World
,自然就能夠做任何有副作用的事情,包括但不限于 I/O。(實際上,對外部狀态的管理本質上就是 I/O 操作。)
但是,如何生成我們所需的 IO 對象就成了一個問題。比如,假設我們需要向螢幕上輸出一個字元串,那麼我們可能需要定義一個雙參數函數,如下:
println :: (String, World) -> (World, ())
但這樣就不符合 IO 的定義了。這可以借助函數柯裡化來解決:
println :: String -> (World -> (World, ()))
這個定義經過簡化之後就成為了:
println :: String -> IO ()
這正是系統函數
putStrLn
的定義。
在 Haskell 中,IO 被定義成一個抽象資料類型,隻有系統庫和 Monad 才能夠構造 IO 對象。這就避免了
World
對象的暴露。
用 Monad 實作 IO 的串聯
毫無疑問,Haskell 的标準庫中實作了
Monad IO
。
Monad IO
中的
>>=
操作符的定義如下:
>>= :: IO a -> (a -> IO b) -> IO b
這與我們上面讨論的狀态轉移函數十分類似。
為了進一步簡化代碼,Haskell 中提供了
do
文法。一個經典的例子是:
sayHello = do
putStrLn "What is your name?"
name <- getLine
putStrLn $ "Hello, " ++ name
在這裡,我們主要關心這個文法糖拆開之後是什麼樣子。很簡單:
sayHello = \
putStrLn "What is your name?" >> \
getLine >>= \
(\name -> putStrLn $ "Hello, " ++ name)
其中,資料的流動如下:
- World_0 →
→ World_1putStrLn "What is your name?"
- World_1 →
→ (World_2,getLine
)name
-
IO_3 = putStrLn $ "Hello, " ++ name
- World_2 →
→ World_3IO_3
Bonus:Haskell 中的 FFI
現代的程式設計語言中難免會使用到其它語言編寫的庫。比如 Windows API 就是以 C 語言接口的形式提供給程式員的。在一種語言中調用其它語言的技術稱為 FFI(foreign function interface)。
但在像 Haskell 這樣的函數式程式設計語言中,調用 FFI 函數存在一個根本性的問題,那就是 FFI 函數幾乎一定是有副作用的,這就破壞了函數式程式設計的基本規則。
在 Haskell 中,可能是為了簡潔性和 binding 的便利性,并沒有對 FFI 函數作出過多限制。比如以下聲明:
foreign import ccall "exp" c_exp :: Double -> Double
定義了一個 純函數
double exp(double);
的 FFI 接口。
如果目标函數是有副作用的,則需要把傳回值類型聲明成
IO
的形式,比如:
foreign import ccall "my_func" myFunc :: Int -> IO Double
如果您希望了解更多有關 Haskell FFI 的内容,請參見 Haskell Wiki。
Bonus:一般化的 Monad
在前文中,我們介紹了用 Monad 實作串聯狀态轉移操作的方法。但 Monad 實際上還有更加廣泛的用途。
回憶 Monad 中的
>>=
操作的定義:
>>= :: m a -> (a -> m b) -> m b
在上文中,我們把
StateTransDB
代入 m 中,實作了 資料庫狀态轉移操作 的串聯。但
StateTransDB
是一個複雜類型(狀态轉移函數),我們能否把更簡單的類型代入 m 中呢?
您或許知道 Haskell 标準庫中有一個叫做
Maybe
的類型。它的定義如下:
data Maybe a = Just a | Nothing
也就是說,一個
Maybe a
代表着一個 可空的
a
對象,其中可能含有一個
a
的值,也可能什麼都沒有,類似于 C 語言中的指針,或 Rust 中的
Option<A>
。
類似于狀态轉移函數,
Maybe
類型也帶來了一個附加問題:如果有某個函數傳回
Maybe a
,那麼在我們的代碼中,就必須通過模式比對來把這個
Maybe a
展開成
a
,才能繼續調用之後的操作。
假定我們又定義了一個不可變的資料庫
DB
,它的查詢操作名為
doQuery
,定義如下:
doQuery :: (DB, Query) -> Maybe Record
假設我們需要進行一個多層的嵌套查詢。如果沒有文法糖,那麼我們的代碼可能就需要寫成這樣:
r = case doQuery (db, q1) of
Nothing -> Nothing
Just r1 -> case doQuery (db, (q2 r1)) of
Nothing -> Nothing
Just r2 -> case doQuery (db, (q3 r2)) of
Nothing -> Nothing
Just r3 -> ...
這就造成了與上文一樣的問題,并且因為模式比對的存在,使得代碼層數嚴重加深,造成了程式員很忌諱的「箭頭形嵌套」。
解決方法是:我們可以像上文中一樣,定義一個
then
函數:
then :: Maybe a -> (a -> Maybe b) -> Maybe b
m `then` f = case m of
Nothing -> Nothing
Just a -> f a
這個
then
函數的作用就是:給定前一個操作的結果
Maybe a
和一個 輸入
a
,輸出
Maybe b
的中間函數,輸出中間函數的結果
Maybe b
。
借助
then
函數,我們的多層嵌套查詢代碼就可以改進成如下這樣:
r = doQuery (db, q1) `then` \r1 ->
doQuery (db, (q2 r1)) `then` \r2 ->
doQuery (db, (q3 r2)) `then` \r3 -> ...
在 Haskell 标準庫中,
Maybe
類型也實作了 Monad,而
then
函數類型實際上就是
Monad Maybe
類型中的
>>=
操作:
>>= :: Maybe a -> (a -> Maybe b) -> Maybe b
借助 Haskell 的
do
文法,上面的代碼還能夠繼續簡化成:
r = do
r1 <- doQuery (db, q1)
r2 <- doQuery (db, (q2 r1))
r3 <- doQuery (db, (q3 r2))
...
return rn
注意:上面的代碼可能會造成一種「無論前一個操作是否成功,後一個操作都會被執行」的錯覺。這種錯覺是
Monad Maybe
的
>>=
操作的定義導緻的,因為
>>=
操作在遇到
Nothing
的時候,根本不會調用中間函數,自然也不會觸發後續的操作。這種錯覺是由指令式程式設計語言的刻闆印象導緻的,若要擺脫這樣的錯覺,除了了解 Monad 和
do
背後的邏輯之外,還需要勤加練習才能熟練掌握。
如果對上面的構造過程加以抽象,我們可以發現:
Maybe
實際上代表着一種類型包裝器,它可以為現有的類型增加一些附加的功能(對于狀态轉移函數來說是狀态管理,對于
Maybe
來說是 nullable)。而如果遇到了一些需要 把類型包裝器解開,暴露内部的類型 的情況,則 Monad 作為一種程式設計範式,可以降低代碼的複雜度,并且對于一名熟練的程式員來說,還能夠降低閱讀代碼的負擔。
如果您對 Monad 在函數式程式設計語言中的應用比較感興趣,您還可以參考另一篇文章:15 分鐘了解 Monad - Angry Bugs on 知乎