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,但读起来令人费解;所以请不要那样写。用一致且有意义的组合来编写你的代码。
- ↑ 这解释了为什么,正如我们在"模式匹配"章节中指出的那样,列表推导中的模式匹配失败被静默地忽略了。