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 表示式儘可能地擴充套件。在呼叫最終的動作製造器 mk_action3 時,x1 仍然在範圍內。我們可以使用單獨的行和縮排來更清晰地改寫 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,但讀起來令人費解;所以請不要那樣寫。用一致且有意義的組合來編寫你的程式碼。
- ↑ 這解釋了為什麼,正如我們在"模式匹配"章節中指出的那樣,列表推導中的模式匹配失敗被靜默地忽略了。