跳轉到內容

Haskell/do 符號

來自 Wikibooks,開放書籍,開放世界
(從 Haskell/do Notation 重定向)

使用 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 表示式儘可能地擴充套件。在呼叫最終的動作製造器 mk_action3 時,x1 仍然在範圍內。我們可以使用單獨的行和縮排來更清晰地改寫 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. 這解釋了為什麼,正如我們在"模式匹配"章節中指出的那樣,列表推導中的模式匹配失敗被靜默地忽略了。
華夏公益教科書