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 表达式尽可能地扩展。x1
在我们调用最终的操作制造者mk_action3
时仍然在范围内。我们可以通过使用单独的行和缩进,更清晰地重写 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,但读起来令人费解;所以请不要这样写。用一致且有意义的组合来编写你的代码。
- ↑ 这解释了为什么,正如我们在 "模式匹配" 章节 中指出的,列表推导中的模式匹配失败会被静默忽略。