跳转至内容

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