跳转到内容

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. 这解释了为什么,正如我们在"模式匹配"章节中指出的那样,列表推导中的模式匹配失败被静默地忽略了。
华夏公益教科书