天天看點

Haskell 中的 Monad 和 IO

對于 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 →

    putStrLn "What is your name?"

    → World_1
  • World_1 →

    getLine

    → (World_2,

    name

    )
  • IO_3 = putStrLn $ "Hello, " ++ name

  • World_2 →

    IO_3

    → World_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 知乎

繼續閱讀