Haskell的“do”关键字是什么作用?

9

我是一名C++/Java程序员,现在正试着学习Haskell(和函数式编程),但感觉进展不顺。我尝试了以下的方法:

isEven :: Int -> Bool
isEven x =
    if mod x 2 == 0 then True
    else False

isOdd :: Int -> Bool
isOdd x =
    not (isEven x)

main =
    print (isEven 2)
    print (isOdd 2)

但是在编译过程中,出现了以下错误:

ghc --make doubler.hs -o Main
[1 of 1] Compiling Main             ( doubler.hs, doubler.o )

doubler.hs:11:5: error:
    • Couldn't match expected type ‘(a0 -> IO ()) -> Bool -> t’
              with actual typeIO ()’The function ‘print’ is applied to three arguments,
      but its typeBool -> IO ()’ has only one
      In the expression: print (isEven 2) print (isOdd 2)
      In an equation for ‘main’: main = print (isEven 2) print (isOdd 2)
    • Relevant bindings include main :: t (bound at doubler.hs:10:1)
make: *** [all] Error 1

我在网上看到一些带有“do”关键字的代码,所以我尝试了以下这样写:

isEven :: Int -> Bool
isEven x =
    if mod x 2 == 0 then True
    else False

isOdd :: Int -> Bool
isOdd x =
    not (isEven x)

main = do
    print (isEven 2)
    print (isOdd 2)

它的运行方式与我预期的完全一样。

这里发生了什么?为什么第一个代码片段不起作用?添加“do”实际上是做什么的?

顺便说一下,我在互联网上看到了与“do”关键字相关的“单子”,这与此有关吗?


我认为这个问题太宽泛了,你最好阅读一本Haskell书籍中的“Monad”章节。你可以通过在此网站或谷歌上搜索“desugaring do notation haskell”来回答你的问题,但你可能需要更多的背景知识。 - jberryman
3
do 表达式是常见单子代码的语法糖。没有 do,可以这样写:main = print (isEven 2) >> print (isOdd 2) - Alec
4个回答

17
为什么第一个代码片段不起作用?
在 do 块之外,换行符没有任何意义。因此,你对 main 的第一个定义等同于 main = print (isEven 2) print (isOdd 2),这会失败,因为 print 只接受一个参数。
现在你可能想知道为什么我们不能只使用换行符来表示一个函数应该在另一个函数之后调用。问题在于 Haskell 通常是惰性和纯函数的,因此函数没有副作用,也没有有意义的调用一个函数之后的概念。
那么 print 是如何工作的呢?print 是一个接受字符串并产生类型为 IO () 的结果的函数。IO 是表示可能具有副作用的操作的类型。main 生成了这种类型的值,描述的操作将被执行。虽然没有有意义的调用一个函数之后的概念,但是有执行一个 IO 值的操作后再执行另一个 IO 值的操作的有意义的概念。为此,我们使用 >> 运算符将两个 IO 值链接在一起。
我在互联网上看到了与 "do" 关键字相关的 "monads",这与此有关吗?
是的,Monad 是一种类型类(如果你还不知道这些是什么:它们类似于 OO 语言中的接口),其中包括 > > 和 > >= 等函数。IO 是该类型类的一个实例(在 OO 术语中:实现该接口的类型之一),使用这些方法将多个操作链接在一起。
do 语法是使用 > > 和 > >= 的更方便的方式。具体而言,不使用 do,你对 main 的定义等同于以下内容:
main = (print (isEven 2)) >> (print (isOdd 2))
(括号不是必需的,但我添加它们是为了避免有关优先级的任何混淆。)
因此,main 生成一个执行 print (isEven 2) 步骤的 IO 值,然后执行 print (isOdd 2) 的步骤。

除了在do块中,没有一个有意义的概念是调用一个函数然后再调用另一个函数。在do块内部,有一个先后顺序的概念。这是否意味着Haskell只在do块之外才是纯函数式的? - cowlinator

5

我认为目前你只能接受它。是的,do符号是单子类型类的语法糖。您的代码可以被转换成以下形式:

main = print (isEven 2) >> print (isOdd 2)

(>>)的意思类似于在这种情况下之后做这个。然而,在StackOverflow答案中,试图解释Haskell IO和monad并不是一个好主意。相反,我建议您继续学习,直到您的书籍或者其他学习资源覆盖了该主题。

这里有一个快速示例,展示了您可以在IO-do内部执行的操作。不要太过关注语法细节。

import System.IO
main = do
  putStr "What's your name? "  -- Print strings
  hFlush stdout                -- Flush output
  name <- getLine              -- Get input and save into variable name
  putStrLn ("Hello " ++ name)
  putStr "What's your age? "
  hFlush stdout
  age <- getLine
  putStr "In one year you will be "
  print (read age + 1)         -- convert from string to other things with read
                               -- use print to print things that are not strings

1
你可以在“do”语法中使用超过两个语句吗? - Dovahkiin
1
你可能想在 putStr 调用之后添加 hFlush stdout,以确保如果启用了行缓冲,则显示提示符。 - Jon Purdy

3
Haskell函数是“纯净的”,除了“数据依赖”(作为另一个函数参数使用的函数的结果值)之外没有顺序概念。基本上,没有要排序的语句,只有值。
有一个类型构造器称为IO。它可以应用于其他类型:IO IntIO CharIO StringIO sometype的意思是:“该值是在真实世界中执行一些操作并返回sometype值的配方,一旦运行时执行该配方”。
这就是为什么main的类型是IO()。你提供了一个在现实世界中执行任务的配方。 ()是一个只有一个值的类型,不提供任何信息。 main仅为其在现实世界中的影响而执行。
有许多用于组合IO配方的运算符。一个简单的运算符是>>,它接受两个配方并返回一个配方,用于执行第一个配方,然后执行第二个配方。请注意,即使实际组合配方类似于命令式编程的顺序语句(“打印此消息,然后打印另一个消息”),组合也是用纯函数进行的。
为了简化构建这些“命令式配方”的过程,创建了do-notation。它允许你编写类似于命令式语言的顺序语句,但它会展开成函数应用程序。你可以用常规函数应用程序编写你可以在do-notation中编写的所有内容(有时不太清晰)。

1

你知道函数的结果应该只取决于它的输入,因此让我们对print进行建模以反映这一点:

print :: String -> RealWorld -> (RealWorld, ())

main 现在看起来是这样的:

main rw0 = let (rw1, _) = print (isEven 2) rw0 in
                          print (isOdd 2) rw1

现在让我们定义 bind f g rw = let (rw', ret) = f rw in g rw',它通过 RealWorld 状态进行传递,并重写片段以使用它:
main = bind (print (isEven 2))
            (print (isOdd 2))

现在让我们介绍一些语法糖,它们可以为我们执行绑定操作。
main = do print (isEven 2)
          print (isOdd 2)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接