Haskell/理解 Monad/IO
Haskell 的兩個主要特徵是純函式和惰性求值。所有 Haskell 函式都是純函式,這意味著在給定相同的引數的情況下,它們會返回相同的結果。惰性求值意味著,預設情況下,Haskell 值只有在程式的某些部分需要它們時才會被求值——也許永遠不會被求值,如果它們從未被使用——並且儘可能避免對同一值的重複求值。
純函式和惰性求值帶來了許多優勢。特別是,純函式可靠且可預測;它們簡化了除錯和驗證。測試用例也可以輕鬆設定,因為我們可以確保除了引數之外,沒有任何其他因素會影響函式的結果。由於完全包含在程式內部,Haskell 編譯器可以徹底評估函式以最佳化編譯後的程式碼。但是,涉及與程式外部世界互動的輸入和輸出操作無法透過純函式來表達。此外,在大多數情況下,I/O 無法惰性執行。由於惰性計算僅在它們的返回值變得必要時才執行,因此不受約束的惰性 I/O 將使現實世界效果的執行順序不可預測。
無法忽略此問題,因為任何有用的程式都需要執行 I/O,即使只是顯示結果。既然如此,我們如何管理像開啟網路連線、寫入檔案、從外部世界讀取輸入或任何其他超出計算值的動作?主要見解是:動作不是函式。IO 型別建構函式提供了一種將動作表示為 Haskell 值的方法,以便我們可以用純函式來操縱它們。在序言章中,我們預見到了這種解決方案的一些關鍵特徵。現在我們也知道 IO 是一個 Monad,我們可以結束我們在那裡開始的討論。
讓我們將函式與 I/O 相結合,建立一個完整的程式,它將
- 要求使用者輸入一個字串
- 讀取他們的字串
- 使用
fmap應用一個函式shout,該函式將字串中的所有字母都大寫 - 寫入結果字串
module Main where
import Data.Char (toUpper)
import Control.Monad
main = putStrLn "Write your string: " >> fmap shout getLine >>= putStrLn
shout = map toUpper
我們有一個完整的程式,但我們沒有包含任何型別定義。哪些部分是函式,哪些是 IO 操作或其他值?我們可以在 GHCi 中載入我們的程式並檢查型別
main :: IO ()
putStrLn :: String -> IO ()
"Write your string: " :: [Char]
(>>) :: Monad m => m a -> m b -> m b
fmap :: Functor m => (a -> b) -> m a -> m b
shout :: [Char] -> [Char]
getLine :: IO String
(>>=) :: Monad m => m a -> (a -> m b) -> m b
哇,那裡有很多資訊。我們之前都見過這些,但讓我們回顧一下。
main 是 IO ()。那不是一個函式。函式的型別是 a -> b。我們的整個程式都是一個 IO 操作。
putStrLn 是一個函式,但它會生成一個 IO 操作。 "Write your string: " 文字是一個 String(記住,它只是 [Char] 的同義詞)。它被用作 putStrLn 的引數,並被合併到生成的 IO 操作中。所以,putStrLn 是一個函式,但 putStrLn x 會計算為一個 IO 操作。IO 型別中的 () 部分(稱為單元型別)表示沒有可傳遞給任何後續函式或操作的值。
最後一點是關鍵。我們有時非正式地說一個 IO 操作“返回”某些東西;但是,太字面地理解會導致混淆。當我們談論函式返回結果時,意義很明確,但 IO 操作不是函式。讓我們跳到 getLine——一個確實提供值的 IO 操作。getLine 不是一個返回 String 的函式,因為getLine 不是一個函式。相反,getLine 是一個 IO 操作,當被求值時,它會具體化一個 String,然後可以透過例如 fmap 和 (>>=) 傳遞給後續函式。
當我們使用 getLine 獲取 String 時,該值是 Monadic,因為它被包裝在 IO 函子中(恰好是一個 Monad)。我們無法將該值直接傳遞給接受普通(非 Monadic 或非函子)值的函式。fmap 負責接收一個非 Monadic 函式,同時傳入和返回 Monadic 值。
正如我們已經看到的,(>>=) 負責將 Monadic 值傳遞給一個接受非 Monadic 值並返回 Monadic 值的函式。將 fmap 的非 Monadic 結果接收並返回一個 Monadic 值,然後 (>>=) 將底層非 Monadic 值傳遞給下一個函式,這似乎效率低下。然而,正是這種鏈式操作,才建立了可靠的排序,使 Monad 在將純函式與 IO 操作整合方面非常有效。
鑑於對排序的強調,do 語法 在 IO Monad 中特別有用。我們的程式
putStrLn "Write your string: " >> fmap shout getLine >>= putStrLn
可以寫成
do putStrLn "Write your string: "
string <- getLine
putStrLn (shout string)
將 IO Monad 視為一種計算的一種方式,即在執行輸入和輸出操作的同時,透過更改世界狀態來提供型別為 a 的值。顯然,你無法真正設定世界狀態;它對你隱藏,因為 IO 函子是抽象的(也就是說,你無法深入研究它以檢視底層值,這種情況與我們在Maybe 情況下看到的情況不同)。
請理解,這種將宇宙視為透過 IO 影響和受到 Haskell 值影響的物件的想法只是一個比喻;充其量是鬆散的解釋。更實際的事實是,IO 只是將一些非常底層的操作引入 Haskell 語言。[1] 記住,Haskell 是一個抽象,並且 Haskell 程式必須編譯成機器程式碼才能實際執行。IO 的實際工作原理髮生在更低的抽象級別,並且被連線到 Haskell 語言的定義中。[2]
在談論 Haskell 中的 I/O 時,形容詞“純”和“不純”經常出現。為了澄清它們的含義,我們將重新討論序言章節中的引用透明性。考慮以下程式碼段
speakTo :: (String -> String) -> IO String
speakTo fSentence = fmap fSentence getLine
-- Usage example.
sayHello :: IO String
sayHello = speakTo (\name -> "Hello, " ++ name ++ "!")
在大多數其他程式語言中,沒有為 I/O 操作單獨定義型別,speakTo 將具有類似於以下型別的型別
speakTo :: (String -> String) -> String
但是,有了這種型別,speakTo 根本就不是函式!函式在給定相同的引數時會產生相同的結果;但是,speakTo 傳遞的 String 也取決於在終端提示符處鍵入的內容。在 Haskell 中,我們透過返回 IO String 來避免這個陷阱,IO String 不是 String,而是一個承諾,即透過執行某些涉及 I/O 的指令(在本例中,I/O 包括從終端獲取一行輸入)將傳遞某些 String。儘管每次評估 speakTo 時,String 可能不同,但 I/O 指令始終相同。
當我們說 Haskell 是一種純函式式語言時,我們的意思是所有函式都是真正的函式——換句話說,Haskell 表示式始終引用透明。如果 speakTo 具有我們上面提到的問題型別,引用透明性就會被破壞:sayHello 將是一個 String,但用任何特定字串替換它都會破壞程式。
儘管 Haskell 是純函式式的,但 IO 操作可以被稱為不純,因為它們對外部世界的影響是副作用(而不是完全包含在 Haskell 中的正常效果)。缺乏純度的程式語言在與各種計算相關的許多其他地方可能存在副作用。但是,純函式式語言保證即使是不純值的表示式也是引用透明的。這意味著我們可以用純函式的方式來討論、推理和處理不純度,使用純函式機制,如函子和平行。雖然 IO 操作是不純的,但所有操縱它們的 Haskell 函式仍然是純函式。
函式式純度加上 I/O 在型別中出現的事實,以多種方式使 Haskell 程式設計師受益。關於引用透明性的保證極大地提高了編譯器最佳化的潛力。透過型別本身可以區分 IO 值,這使得我們能夠立即知道我們在哪裡使用副作用或不透明值。由於 IO 本身只是一個函子,我們最大程度地保持了與純函式相關的可預測性和易推理性。
當我們介紹單子時,我們說過單子表示式可以解釋為命令式語言的語句。這種解釋對於IO來說是直接有說服力的,因為圍繞 IO 動作的語言看起來很像傳統的命令式語言。然而,必須明確的是,我們談論的是一種解釋。我們不是說單子或do符號將 Haskell 變成了一種命令式語言。重點僅僅是你可以用命令式語句來檢視和理解單子程式碼。語義可能是命令式的,但單子和(>>=)的實現仍然是純函式式的。為了使這種區別更加清晰,讓我們看一個小的說明。
int x;
scanf("%d", &x);
printf("%d\n", x);
這是一段 C 程式碼片段,C 是一種典型的命令式語言。在其中,我們聲明瞭一個變數x,用scanf從使用者輸入中讀取它的值,然後用printf列印它。我們可以在一個IO do 塊中,寫一個執行相同功能並且看起來非常相似的 Haskell 程式碼片段。
x <- readLn
print x
從語義上來說,這兩個程式碼片段幾乎是等價的。[3] 然而,在 C 程式碼中,這些語句直接對應於程式要執行的指令。另一方面,Haskell 程式碼片段會被反糖化為
readLn >>= \x -> print x
反糖化後的版本沒有語句,只有函式被應用。我們透過資料依賴關係間接地告訴程式操作的順序:當我們用(>>=)連結單子計算時,我們透過將函式應用於早期結果來獲得後期結果。只是碰巧的是,例如,評估print x會導致字串被列印到終端。
當使用單子時,Haskell 允許我們在保持函數語言程式設計優勢的同時,編寫具有命令式語義的程式碼。
到目前為止,我們唯一用到的 I/O 原語是putStrLn和getLine以及它們的微小變化。然而,標準庫提供了許多其他有用的函式和涉及IO的動作。我們在Haskell 實踐中的 IO 章節中介紹了一些最重要的內容,包括從檔案讀取和寫入檔案所需的基本功能。
鑑於單子允許我們以完全通用的方式表達動作的順序執行,我們可以用它們來實現常見的迭代模式,例如迴圈嗎?在本節中,我們將介紹標準庫中的一些函式,這些函式允許我們做到這一點。雖然這裡展示的例子應用於IO,但請記住,以下想法適用於所有單子。
記住,單子值沒有什麼神奇之處;我們可以像操作 Haskell 中的任何其他值一樣操作它們。知道這一點,我們可能會想嘗試以下函式來獲取五行使用者輸入。
fiveGetLines = replicate 5 getLine
然而,這並不能奏效(在 GHCi 中試一試!)。問題是replicate在這種情況下會生成一個動作列表,而我們想要一個返回列表的動作(也就是說,IO [String]而不是[IO String])。我們需要的是一個摺疊來遍歷動作列表,執行它們並將結果組合成一個單一的列表。碰巧的是,Prelude 函式可以做到這一點:sequence。
sequence :: (Monad m) => [m a] -> m [a]
因此,我們得到所需的動作。
fiveGetLines = sequence $ replicate 5 getLine
replicate和sequence形成了一個吸引人的組合,所以Control.Monad提供了一個replicateM函式,用於重複一個動作任意次數。Control.Monad以同樣的精神提供了許多其他方便的函式——單子壓縮、摺疊等等。
fiveGetLinesAlt = replicateM 5 getLine
一個特別重要的組合是map和sequence。它們共同允許我們從一個值列表中建立動作,按順序執行它們,並收集結果。mapM,一個 Prelude 函式,捕捉了這種模式。
mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
我們還有一些上述函式的變體,它們在名稱中帶有一個下劃線,例如sequence_、mapM_和replicateM_。這些函式會丟棄任何最終值,因此適合你只關心執行動作的情況。與沒有下劃線的對應項相比,這些函式就像(>>)和(>>=)之間的區別。例如,mapM_具有以下型別。
mapM_ :: (Monad m) => (a -> m b) -> [a] -> m ()
最後,值得一提的是,Control.Monad還提供了forM和forM_,它們是mapM和mapM_的翻轉版本。forM_恰好是命令式 for-each 迴圈的 Haskell 慣用語;並且型別簽名巧妙地暗示了這一點。
forM_ :: (Monad m) => [a] -> (a -> m b) -> m ()
| 練習 |
|---|
|
筆記
- ↑ 技術術語是“原始”,就像原始操作一樣。
- ↑ 當然,所有高階程式語言都可以這樣說。順便說一句,Haskell 的 IO 操作實際上可以透過外部函式介面 (FFI) 進行擴充套件,FFI 可以呼叫 C 庫。由於 C 可以使用內聯彙編程式碼,因此 Haskell 可以間接地參與計算機可以執行的任何操作。儘管如此,Haskell 函式只會間接地將這些外部操作作為
IO函子的值進行操作。 - ↑ 一個區別是
x在 C 中是一個可變變數,因此可以在一個語句中宣告它,並在下一個語句中設定它的值;Haskell 從不允許這種可變性。如果我們想更緊密地模仿 C 程式碼,我們可以使用IORef,它是一個包含可以被破壞性更新的值的單元。出於顯而易見的原因,IORef只能在IO單子中使用。