跳轉至內容

Haskell/do 標記

來自華夏公益教科書,開放書籍,開放世界

使用do 塊作為一種替代的單子語法最早是在 簡單輸入和輸出 一章中引入的。在那裡,我們使用do 來對輸入/輸出操作進行排序,但我們還沒有介紹單子。現在,我們可以看到IO 是另一個單子。

由於以下所有示例都涉及IO,因此我們將把計算/單子值稱為操作(就像我們在本書的早期部分所做的那樣)。當然,do 可以與任何單子一起使用;它在工作原理上沒有IO 特定的東西。

then 運算子翻譯成

[編輯 | 編輯原始碼]

(>>) (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 運算子翻譯成

[編輯 | 編輯原始碼]

bind 運算子(>>=) 稍微難以從do 標記中翻譯來翻譯去。(>>=) 將一個值(即操作或函式的結果)傳遞到繫結序列的下游。do 標記使用<- 將一個變數名分配給傳遞的值。

do { x1 <- action1
   ; x2 <- action2
   ; mk_action3 x1 x2 }

如果每行程式碼都被縮排以對齊(注意:在這種情況下要小心使用製表符和空格的混合;使用明確的大括號和分號,縮排不起作用,也沒有危險),則大括號和分號是可選的。

x1x2action1action2 的結果。例如,如果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]

fail 方法

[編輯 | 編輯原始碼]

在上面,我們說帶有 lambda 的程式碼片段“大體上等同於”do 塊。這種翻譯並不精確,因為do 標記添加了對模式匹配失敗的特殊處理。當放置在<--> 的左側時,x1x2 是正在匹配的模式。因此,如果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 實際做什麼取決於單子例項。雖然它通常會重新丟擲模式匹配錯誤,但包含某種錯誤處理的單子可能會以它們自己的特定方式處理失敗。例如,Maybefail _ = Nothing;類似地,對於列表單子,fail _ = [][2]

fail 方法是do 標記的人工產物。不要直接呼叫fail,而應該在你確信fail 會對正在使用的單子做一些明智的事情時,依賴自動處理模式匹配失敗。

示例:使用者互動式程式

[編輯 | 編輯原始碼]

備註

我們將與使用者進行互動,因此我們將交替使用putStrgetLine。為了避免輸出中出現意外結果,我們必須在匯入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 在那裡將很有用。

註釋

  1. 實際上,在這種情況下不需要縮排。這個也是有效的:
    action1 >>= \ x1 -> action2 >>= \ x2 -> action3 x1 x2 

    當然,如果我們想要,我們可以使用更多縮排。以下是一個極端的例子:

    action1  >>=  \  x1  ->  action2  >>=  \  x2  ->  action3  x1  x2 

    雖然這種縮排確實過分了,但它可能更糟:

    action1  >>= \  x1  -> action2 >>=  \  x2 ->  action3 x1  x2 

    這是有效的 Haskell,但讀起來令人費解;所以請不要這樣寫。用一致且有意義的組合來編寫你的程式碼。

  2. 這解釋了為什麼,正如我們在 "模式匹配" 章節 中指出的,列表推導中的模式匹配失敗會被靜默忽略。
華夏公益教科書