Haskell/do 標記
使用do 塊作為一種替代的單子語法最早是在 簡單輸入和輸出 一章中引入的。在那裡,我們使用do 來對輸入/輸出操作進行排序,但我們還沒有介紹單子。現在,我們可以看到IO 是另一個單子。
由於以下所有示例都涉及IO,因此我們將把計算/單子值稱為操作(就像我們在本書的早期部分所做的那樣)。當然,do 可以與任何單子一起使用;它在工作原理上沒有IO 特定的東西。
(>>) (then) 運算子在do 標記中和在未加糖的程式碼中工作方式幾乎完全相同。例如,假設我們有一系列操作,如下所示
putStr "Hello" >>
putStr " " >>
putStr "world!" >>
putStr "\n"
我們可以用do 標記將它重寫為
do { putStr "Hello"
; putStr " "
; putStr "world!"
; putStr "\n" }
(使用可選的大括號和分號明確地,為了清晰起見)。這組指令幾乎與任何命令式語言中的指令匹配。在 Haskell 中,我們可以連結任何操作,只要它們都在同一個單子中。在IO 單子的上下文中,這些操作包括寫入檔案,開啟網路連線,或向用戶詢問輸入。
以下是do 標記到未加糖的 Haskell 程式碼的逐步翻譯
do { action1 -- by monad laws equivalent to: do { action1
; action2 -- ; do { action2
; action3 } -- ; action3 } }
變成
action1 >>
do { action2
; action3 }
等等,直到do 塊為空。
bind 運算子(>>=) 稍微難以從do 標記中翻譯來翻譯去。(>>=) 將一個值(即操作或函式的結果)傳遞到繫結序列的下游。do 標記使用<- 將一個變數名分配給傳遞的值。
do { x1 <- action1
; x2 <- action2
; mk_action3 x1 x2 }
如果每行程式碼都被縮排以對齊(注意:在這種情況下要小心使用製表符和空格的混合;使用明確的大括號和分號,縮排不起作用,也沒有危險),則大括號和分號是可選的。
x1 和 x2 是action1 和action2 的結果。例如,如果action1 是一個IO Integer,那麼x1 將被繫結到一個Integer 值。本示例中的兩個繫結值將作為引數傳遞給mk_action3,它建立了第三個操作。do 塊大體上等同於以下普通 Haskell 程式碼片段
action1 >>= (\ x1 -> action2 >>= (\ x2 -> mk_action3 x1 x2 ))
第一個(最左側)繫結運算子(>>=)的第二個引數是一個函式(lambda 表示式),它指定了如何處理作為繫結第一個引數傳遞的操作的結果。因此,lambda 表示式的鏈將結果傳遞到下游。括號可以省略,因為 lambda 表示式儘可能地擴充套件。x1 在我們呼叫最終的操作製造者mk_action3 時仍然在範圍內。我們可以透過使用單獨的行和縮排,更清晰地重寫 lambda 表示式的鏈
action1
>>=
(\ x1 -> action2
>>=
(\ x2 -> mk_action3 x1 x2 ))
這清楚地顯示了每個 lambda 函式的範圍。為了更像do 標記一樣對事物進行分組,我們可以這樣顯示它
action1 >>= (\ x1 ->
action2 >>= (\ x2 ->
mk_action3 x1 x2 ))
這些演示差異僅僅是幫助可讀性而已。[1]
在上面,我們說帶有 lambda 的程式碼片段“大體上等同於”do 塊。這種翻譯並不精確,因為do 標記添加了對模式匹配失敗的特殊處理。當放置在<- 或-> 的左側時,x1 和x2 是正在匹配的模式。因此,如果action1 返回了一個Maybe Integer,我們就可以寫一個do 塊,像這樣...
do { Just x1 <- action1
; x2 <- action2
; mk_action3 x1 x2 }
...並且x1 是一個Integer。在這種情況下,如果action1 返回Nothing 會發生什麼?通常,程式會因非窮盡模式錯誤而崩潰,就像我們在對空列表呼叫head 時遇到的錯誤一樣。然而,使用do 標記,失敗將使用相關單子的fail 方法來處理。上面的do 塊翻譯成
action1 >>= f
where f (Just x1) = do { x2 <- action2
; mk_action3 x1 x2 }
f _ = fail "..." -- A compiler-generated message.
fail 實際做什麼取決於單子例項。雖然它通常會重新丟擲模式匹配錯誤,但包含某種錯誤處理的單子可能會以它們自己的特定方式處理失敗。例如,Maybe 有fail _ = Nothing;類似地,對於列表單子,fail _ = []。[2]
fail 方法是do 標記的人工產物。不要直接呼叫fail,而應該在你確信fail 會對正在使用的單子做一些明智的事情時,依賴自動處理模式匹配失敗。
備註
我們將與使用者進行互動,因此我們將交替使用putStr 和getLine。為了避免輸出中出現意外結果,我們必須在匯入System.IO 時停用輸出緩衝。為此,請將hSetBuffering stdout NoBuffering 放在do 塊的頂部。要以其他方式處理這個問題,您需要在每次與使用者互動(即getLine)之前顯式地重新整理輸出緩衝區,使用hFlush stdout。如果您使用 ghci 測試此程式碼,則不會出現此類問題。
考慮這個簡單的程式,它詢問使用者他們的名字和姓氏
nameDo :: IO ()
nameDo = do putStr "What is your first name? "
first <- getLine
putStr "And your last name? "
last <- getLine
let full = first ++ " " ++ last
putStrLn ("Pleased to meet you, " ++ full ++ "!")
將其轉換為普通單子程式碼的可能方法
nameLambda :: IO ()
nameLambda = putStr "What is your first name? " >>
getLine >>= \ first ->
putStr "And your last name? " >>
getLine >>= \ last ->
let full = first ++ " " ++ last
in putStrLn ("Pleased to meet you, " ++ full ++ "!")
在像這樣的情況下,我們只是想連結幾個操作,do 標記的命令式風格感覺自然而方便。相比之下,帶有顯式繫結和 lambda 的單子程式碼是一種需要學習的愛好。
請注意,上面的第一個示例在do 塊中包含一個let 語句。去糖後的版本只是一個普通的let 表示式,其中in 部分是do 語法之後的內容。
do 標記中的最後一條語句是do 塊的整體結果。在前面的示例中,結果是IO () 型別,即IO 單子中的一個空值。
假設我們想重寫這個示例,但返回一個帶有獲取到的名字的IO String。我們只需要新增一個return
nameReturn :: IO String
nameReturn = do putStr "What is your first name? "
first <- getLine
putStr "And your last name? "
last <- getLine
let full = first ++ " " ++ last
putStrLn ("Pleased to meet you, " ++ full ++ "!")
return full
此示例將在IO 單子內“返回”完整姓名作為字串,然後可以在下游的其他地方使用
greetAndSeeYou :: IO ()
greetAndSeeYou = do name <- nameReturn
putStrLn ("See you, " ++ name ++ "!")
這裡,nameReturn 將被執行,並且返回的結果(在nameReturn 函式中被稱為“full”)將被分配給我們的新函式中的變數“name”。nameReturn 的問候部分將被列印到螢幕上,因為它是計算過程的一部分。然後,“再見”資訊也會被打印出來,最終返回的值又變成了IO ()。
如果您熟悉像 C 這樣的命令式語言,您可能會認為 Haskell 中的return 與其他地方的return 相匹配。對示例的一個小變體將消除這種印象
nameReturnAndCarryOn = do putStr "What is your first name? "
first <- getLine
putStr "And your last name? "
last <- getLine
let full = first++" "++last
putStrLn ("Pleased to meet you, "++full++"!")
return full
putStrLn "I am not finished yet!"
額外行中的字串將被打印出來,因為return不是中斷流程的最終語句(就像在 C 和其他語言中一樣)。實際上,nameReturnAndCarryOn 的型別是IO (),——最終putStrLn 操作的型別。在函式被呼叫之後,由return full 建立的IO String 將毫無蹤跡地消失。
作為一種語法上的便利,do 語法並沒有新增任何實質性的內容,但它通常在清晰度和風格方面更受歡迎。然而,對於單個操作,do 根本不需要。Haskell 的“Hello world”僅僅是
main = putStrLn "Hello world!"
像這樣的程式碼片段完全是多餘的
fooRedundant = do { x <- bar
; return x }
由於 單子定律,我們可以簡單地寫成
foo = do { bar } -- which is, further,
foo = bar
一個微妙但至關重要的點與函式組合有關:正如我們已經知道的,上面部分中的 greetAndSeeYou 操作可以改寫為
greetAndSeeYou :: IO ()
greetAndSeeYou = nameReturn >>= (\ name -> putStrLn ("See you, " ++ name ++ "!"))
雖然你可能覺得 lambda 有點難看,但假設我們在別處定義了一個 printSeeYou 函式
printSeeYou :: String -> IO ()
printSeeYou name = putStrLn ("See you, " ++ name ++ "!")
現在,我們可以有一個乾淨的函式定義,既沒有 lambda 也沒有 do
greetAndSeeYou :: IO ()
greetAndSeeYou = nameReturn >>= printSeeYou
或者,如果我們有一個非單子 的 seeYou 函式
seeYou :: String -> String
seeYou name = "See you, " ++ name ++ "!"
那麼我們可以寫成
-- Reminder: fmap f m == m >>= (return . f) == liftM f m
greetAndSeeYou :: IO ()
greetAndSeeYou = fmap seeYou nameReturn >>= putStrLn
記住這個用 fmap 的最後一個例子;我們很快就會回到在單子程式碼中使用非單子函式,fmap 在那裡將很有用。
註釋
- ↑ 實際上,在這種情況下不需要縮排。這個也是有效的:
action1 >>= \ x1 -> action2 >>= \ x2 -> action3 x1 x2
當然,如果我們想要,我們可以使用更多縮排。以下是一個極端的例子:
action1 >>= \ x1 -> action2 >>= \ x2 -> action3 x1 x2
雖然這種縮排確實過分了,但它可能更糟:
action1 >>= \ x1 -> action2 >>= \ x2 -> action3 x1 x2
這是有效的 Haskell,但讀起來令人費解;所以請不要這樣寫。用一致且有意義的組合來編寫你的程式碼。
- ↑ 這解釋了為什麼,正如我們在 "模式匹配" 章節 中指出的,列表推導中的模式匹配失敗會被靜默忽略。