有一個常見的笑話,對於每個 Haskell 程式設計師來說,通過寫一篇「單子教程」的博客文章來完成他們的成長儀式,當他們認為自己終於理解了單子的運作方式時。不過,這類文章已經有很多了,所以我不打算再寫一篇單子教程。然而,根據我的學習經驗,我確實對為什麼人們似乎在單子上掙扎有一些想法,因此,為什麼會有這麼多這樣的教程。
從高層次來看,單子的直覺是它們是編程中序列化的抽象。任何涉及「做這個,然後使用前一個結果做那個」的計算都可以被視為單子。
對於一些實現了 Monad
的類型,我所認為的「計算單子」,如 IO
或 State
,這種直覺是有意義的。我們傾向於將這些類型視為計算,因為它們代表了像「執行輸入 / 輸出操作」或「在跟蹤狀態的同時計算一個值」這樣的動詞。在這個意義上,我們將它們的單子實例解釋為描述這兩個計算如何可以順序組合在一起。
我認為許多 Haskell 新手在理解單子如何運作時,困難在於試圖將這種直覺應用於一組看似無關的類型,我稱之為「數據單子」。這些類型像是列表和 Maybe
,我們通常認為它們代表名詞,即「一組值」或「一個可選的值」。問題在於:這些數據類型如何能夠被序列化在一起?它們又如何是單子?
根本上,我認為這個問題源於單子模糊了 數據 和 計算 之間的界限,儘管我們通常認為這兩者是不同的概念。我主張理解數據單子的技巧是將它們解釋為計算:作為動詞而不是名詞。
然而,數據和計算之間的這條模糊界線並不是單子所特有的。事實上,正是這條模糊的界線位於函數式編程的核心:一級函數。
計算作為數據#
一級函數,或將函數表示為普通值的能力,是函數式編程範式的核心方面。具有一級函數的語言允許任何函數返回另一個函數或將函數作為參數。
它們也精確地表示了數據和計算之間的模糊性。我們通常將函數視為計算:某種接受輸入並計算輸出的東西。但通過一級函數,我們可以將函數用作數據 —— 作為可以像字符串或整數一樣傳遞的對象。
但函數並不是唯一的計算類型,或者至少,我們經常處理更複雜的計算類型,如 IO
和 State
。這些類型在計算一個值的同時,還跟蹤函數正常輸入和輸出之外的其他效果。
Haskell 的類型系統之所以如此強大,部分原因在於它能夠將這些其他類型的計算編碼為數據。事實上,如果我們查看 State
的基本定義,我們會發現它實際上只是一個特定函數簽名的通用包裝器:
newtype State s a = State (s -> (a, s))
也就是說,類型 State s a
是一個接受初始狀態作為輸入的函數,並返回一個更新的值和狀態。通過將這個函數包裝在數據類型中,我們可以更輕鬆地將有狀態的計算作為數據進行處理。也就是說,我們可以將其用作函數的輸入或輸出,或者我們可以將其包裝在其他計算中,這就是更實用的 StateT
單子變換器所做的。
一般來說,將計算視為數據類型的技術在 Haskell 代碼中普遍存在。它允許人們將有副作用的代碼,如 State
或 IO
,與純代碼進行範圍和隔離,並將多個有影響的計算組合在一起。
那麼,單子 是 什麼呢?所有單子代表某種計算,即一個動詞或行動,可以順序組合在一起,並可能帶有一些副作用。然而,這有時並不明確,因為我們經常將這些行動視為普通數據類型。
數據作為計算#
那麼,像列表和 Maybe
這樣的「數據單子」呢?它們也可以被解釋為計算,它們的 Monad
實例允許我們利用一組豐富的通用工具來將其視為計算。
我認為一個例子會更好地解釋這個想法,所以讓我們看看如何用 Maybe
單子來表示數據作為計算。正如我之前所說,我們傳統上將 Maybe a
視為一種通用數據類型,表示該數據的存在(用 Just a
)或該數據的缺失(用 Nothing
)。
但是,Maybe
的單子實例利用了對同一類型的不同解釋。相反,Maybe a
可以被視為一個具有副作用的計算,即在計算 a
的過程中,計算可能返回空值。如果返回空值,則返回 Nothing
,否則返回 Just a
。
如果你將 Maybe
視為計算,那麼該類型的單子實例可能會更有意義。請記住,單子綁定運算符 (>>=
) 的實現告訴我們如何順序執行兩個計算,同時將內部值傳遞下去。我們可以這樣為 Maybe
實現單子:
instance Monad Maybe where
m1 >>= m2 = case m1 of
Just x -> m2 x
Nothing -> Nothing
這個實例告訴我們如何將兩個 Maybe
計算 m1
和 m2
串在一起:如果第一個計算返回一個值(Just x
),則序列返回包裹該值的下一個計算的結果(m2 x
)。否則,如果第一個計算返回 Nothing
,則序列也返回 Nothing
。
從本質上講,這個實現意味著一系列的 Maybe
計算會將內部值沿著鏈傳遞,除非中間的計算返回 Nothing
,在這種情況下整個序列計算 Nothing
。因此,雖然理解像 Maybe
這樣的數據如何能夠被序列化可能很困難,但如果我們將 Maybe
視為計算,則如何序列化它的問題變得更直觀。
這當然只是其中一個例子,並且 Maybe
的綁定實現特別簡單,但我認為它說明了一個重要的觀點:在試圖理解一種類型的單子實例時,首先要理解該類型代表什麼樣的計算,然後再處理這兩個計算如何可以被序列化。
列表單子也存在類似的數據 - 計算平行,我們將列表視為表示非確定性計算,或探索所有可能的執行路徑並合併每個路徑的值。這種對列表的平行解釋可能更好地幫助理解如何實現其單子實例。
結論#
總之,Haskell 允許並鼓勵數據和計算之間的相互作用。它的類型系統將所有計算視為一級值,從而實現計算作為數據的豐富表示。與此同時,單子提供了一組通用工具,用於將數據類型作為可以被序列化和鏈接在一起的計算進行處理。
掌握這兩個概念對我理解如何使用 Haskell 的單子至關重要,因此也許這對你也有幫助。