跳转至内容

Haskell/简单输入输出

来自 Wikibooks,为开放世界提供开放书籍

回到现实世界

[编辑 | 编辑源代码]

除了内部计算值,我们希望我们的程序能够与世界交互。任何语言中最常见的初学者程序只是在屏幕上显示一个“hello world”问候语。以下是一个 Haskell 版本

Prelude> putStrLn "Hello, World!"

putStrLn 是标准 Prelude 工具之一。正如名称中的“putStr”部分所暗示的,它接受一个 String 作为参数并将其打印到屏幕上。我们可以单独使用 putStr,但我们通常会包含“Ln”部分,以便也打印换行符。因此,接下来打印的任何内容都将出现在新行上。

所以你现在应该在想,“putStrLn 函数的类型是什么?”它接受一个 String 并返回……嗯……什么?我们该怎么称呼它?程序没有获得可以用于另一个函数的结果。相反,结果涉及让计算机更改屏幕。换句话说,它在程序外部世界中做了一些事情。什么类型可以代表它?让我们看看 GHCi 告诉我们什么

Prelude> :t putStrLn
putStrLn :: String -> IO ()

"IO" 代表“输入输出”。无论何时在类型中出现 IO,都涉及与程序外部世界的交互。我们将这些 IO 值称为 操作IO 类型的另一部分,在本例中为 (),是操作返回值的类型;也就是说,它返回给程序的类型(而不是它在程序外部执行的操作)。()(发音为“单位”)是一种只包含一个也称为 () 的值的类型(实际上是一个没有元素的元组)。由于 putStrLn 将输出发送到世界但不会返回任何内容给程序,因此 () 用作占位符。我们可以将 IO () 解释为“返回 () 的操作”。

以下是一些使用 IO 的示例

  • 将字符串打印到屏幕
  • 从键盘读取字符串
  • 将数据写入文件
  • 从文件读取数据

是什么让 IO 实际起作用?从 putStrLn 到屏幕上的像素,幕后发生了很多事情,但我们不需要了解任何细节就能编写程序。一个完整的 Haskell 程序实际上是一个大型 IO 操作。在编译后的程序中,此操作称为 main,其类型为 IO ()。从这个角度来看,编写 Haskell 程序就是将操作和函数组合在一起,形成将在程序运行时执行的整体操作 main。编译器负责指示计算机如何执行此操作。

练习
在“类型基础”章节中,我们提到过 openWindow 函数 的类型已被简化。你认为它的类型实际上应该是什么?

使用 do 顺序执行操作

[编辑 | 编辑源代码]

do 语法提供了一种方便的方法来将操作组合在一起(这对于使用 Haskell 做有用的事情至关重要)。考虑以下程序

示例:你的名字是什么?

main = do
  putStrLn "Please enter your name:"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ ", how are you?")

注意

即使 do 语法看起来与我们迄今为止看到的 Haskell 代码非常不同,它也仅仅是少数函数的语法糖,其中最重要的函数是 (>>=) 运算符。我们可以解释这些函数是如何工作的,然后介绍 do 语法。但是,在我们能够给出令人信服的解释之前,我们需要涵盖几个主题。现在就使用 do 是一个务实的捷径,它将允许你立即开始使用 IO 编写完整的程序。我们将在本书的后面看到 do 是如何工作的,从 理解单子 章节开始。


在我们深入研究 do 的工作原理之前,请看一下 getLine。它会进入外部世界(在本例中为终端)并返回一个 String。它的类型是什么?

Prelude> :t getLine
getLine :: IO String

这意味着 getLine 是一个 IO 操作,运行时会返回一个 String。但是输入呢?虽然函数具有类似于 a -> b 的类型,反映了它们接受参数并返回结果,但 getLine 实际上不接受参数。它以终端中的一行中的任何内容作为输入。但是,外部世界中的那行直到我们将其引入 Haskell 程序中才成为具有类型的定义值。

程序在运行时才了解外部世界的状态,因此它无法预测 IO 操作的确切结果。为了管理这些 IO 操作与程序其他方面的关系,必须以预先在代码中定义的、可预测的顺序执行这些操作。对于不执行 IO 的常规函数来说,执行的确切顺序问题不大——只要结果最终到达正确的位置即可。

在我们的姓名程序中,我们正在顺序执行三个操作:带有问候语的 putStrLn、一个 getLine 以及另一个 putStrLn。对于 getLine,我们使用 <- 符号,它分配一个变量名来代表返回的值。我们无法提前知道该值是什么,但我们知道它将使用指定的变量名,因此我们可以随后在其他地方使用该变量(在本例中,用于准备要打印的最终消息)。最终操作定义了整个 do 块的类型。这里,最终操作是 putStrLn 的结果,因此我们的整个程序的类型为 IO ()

练习

编写一个程序,要求用户输入直角三角形的底和高,计算其面积,并将结果打印到屏幕上。交互应该类似于

The base?
3.3
The height?
5.4
The area of that triangle is 8.91
你将需要使用 read 函数将类似于“3.3”的用户字符串转换为类似于 3.3 的数字,并使用 show 函数将数字转换为字符串。

左箭头说明

[编辑 | 编辑源代码]

虽然类似于 getLine 的操作几乎总是用于获取值,但我们并不一定要获取它们。例如,我们可以编写类似于下面的内容

示例:直接执行 getLine

main = do
  putStrLn "Please enter your name:"
  getLine
  putStrLn "Hello, how are you?"

在这种情况下,我们根本没有使用输入,但仍然让用户体验输入其姓名。通过省略 <-,操作将发生,但数据不会被存储或供程序访问。

<- 可用于除最后一个操作之外的任何操作

[编辑 | 编辑源代码]

对哪些操作可以获取其值几乎没有限制。考虑以下示例,其中我们将每个操作的结果放入一个变量中(除了最后一个……稍后会解释)

示例:将所有结果放入一个变量中

main = do
  x <- putStrLn "Please enter your name:"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ ", how are you?")

变量 x 从其操作中获取值,但这在这种情况下没有用,因为操作返回单位值 ()。所以,虽然我们可以从技术上获取任何操作的值,但这并不总是值得的。

那么,最后一个操作呢?为什么我们不能从它那里获取值?让我们看看尝试这样做会发生什么

示例:从最后一个操作中获取值

main = do
  x <- putStrLn "Please enter your name:"
  name <- getLine
  y <- putStrLn ("Hello, " ++ name ++ ", how are you?")

糟糕!错误!

HaskellWikibook.hs:5:2:
    The last statement in a 'do' construct must be an expression

理解这一点需要对 Haskell 有比我们目前更深入的了解。简而言之,在使用 <- 获取操作值的任何行之后,Haskell 都期望另一个操作,因此最终的操作不能有任何 <-

控制操作

[编辑 | 编辑源代码]

if/then/else这样的普通 Haskell 结构可以用于do符号,但是您需要注意一些事项。例如,在简单的“猜数字”程序中,我们有

doGuessing num = do
   putStrLn "Enter your guess:"
   guess <- getLine
   if (read guess) < num
     then do putStrLn "Too low!"
             doGuessing num
     else if (read guess) > num
            then do putStrLn "Too high!"
                    doGuessing num
            else putStrLn "You Win!"

记住if/then/else结构需要三个参数:条件,“then”分支和“else”分支。条件需要具有 Bool 类型,两个分支可以具有任何类型,前提是它们具有相同类型。整个if/then/else结构的类型就是两个分支的类型。

在最外面的比较中,我们有 (read guess) < num 作为条件。它具有正确的类型。现在让我们考虑“then”分支。这里的代码是

          do putStrLn "Too low!"
             doGuessing num

这里,我们正在对两个操作进行排序:putStrLndoGuessing。第一个具有 IO () 类型,这很好。第二个也具有 IO () 类型,这很好。整个计算的类型结果恰好是最终计算的类型。因此,“then”分支的类型也是 IO ()。类似的论证表明“else”分支的类型也是 IO ()。这意味着整个if/then/else结构的类型为 IO (),这正是我们想要的。

注意:如果您发现自己这样想,请小心,“好吧,我已经开始了一个do块;我不需要另一个。”我们不能有这样的代码

    do if (read guess) < num
         then putStrLn "Too low!"
              doGuessing num
         else ...

这里,因为我们没有重复do,编译器不知道 putStrLndoGuessing 调用应该被排序,编译器会认为您正在尝试使用三个参数调用 putStrLn:字符串,函数 doGuessing 和整数 num,因此会拒绝程序。

练习

编写一个程序,提示用户输入姓名。如果姓名是 Simon、John 或 Phil 之一,则告诉用户您认为 Haskell 是一种很棒的编程语言。如果姓名是 Koen,则告诉他们您认为调试 Haskell 很有趣(Koen Classen 是 Haskell 调试工作者之一);否则,告诉用户您不知道他是谁。

(就语法而言,有几种不同的方法可以做到这一点;至少编写一个使用 if / then / else 的版本。)

放大镜下的操作

[编辑 | 编辑源代码]

到目前为止,操作看起来可能很简单,但它们是 Haskell 新手常见的绊脚石。如果您在使用操作时遇到问题,请查看您的问题或疑问是否与以下任何情况匹配。我们建议您现在浏览一下本节,然后在遇到实际问题时再回来查看。

注意您的操作类型

[编辑 | 编辑源代码]

一种诱惑可能是简化我们获取名称并将其打印出来的程序。以下是一个不成功的尝试

示例:为什么这不起作用?

main =
 do putStrLn "What is your name? "
    putStrLn ("Hello " ++ getLine)

哎呀!错误!

HaskellWikiBook.hs:3:26:
    Couldn't match expected type `[Char]'
           against inferred type `IO String'

让我们将上面的示例简化为最简单的形式。您期望这个程序编译吗?

示例:这仍然不起作用

main =
 do putStrLn getLine

在大多数情况下,这是同一个(尝试)程序,除了我们剥离了多余的“您的姓名是什么”提示以及礼貌的“您好”。理解这一点的一个技巧是根据类型来推断它。让我们比较一下

 putStrLn :: String -> IO ()
 getLine  :: IO String

我们可以使用我们在 类型基础 中学到的相同思维方式来弄清楚这是怎么回事。putStrLn 期望一个 String 作为输入。我们没有 String;我们有一些非常接近的东西:IO String。这表示一个操作,当它运行时将提供一个 String。为了获得 putStrLn 想要的 String,我们需要运行该操作,而我们使用方便的左箭头 <- 来做到这一点。

示例:这次有效

main =
 do name <- getLine
    putStrLn name

逐步回到复杂的示例

main =
 do putStrLn "What is your name? "
    name <- getLine
    putStrLn ("Hello " ++ name)

现在,名称就是我们正在寻找的 String,一切又恢复正常了。

也要注意您的表达式类型

[编辑 | 编辑源代码]

所以,我们已经大谈特谈了在不必要的情况下不能使用操作的想法。这方面的反面是,您不能在需要操作的情况下使用非操作。假设我们想问候用户,但这次我们非常兴奋地见到他们,我们必须大声喊出他们的名字

示例:令人兴奋但不正确。为什么?

import Data.Char (toUpper)

main =
 do name <- getLine
    loudName <- makeLoud name
    putStrLn ("Hello " ++ loudName ++ "!")
    putStrLn ("Oh boy! Am I excited to meet you, " ++ loudName)

-- Don't worry too much about this function; it just converts a String to uppercase
makeLoud :: String -> String
makeLoud s = map toUpper s

这会出错...

Couldn't match expected type `IO' against inferred type `[]'
      Expected type: IO t
      Inferred type: String
    In a 'do' expression: loudName <- makeLoud name

这类似于我们上面遇到的问题:我们遇到了一个期望 IO 类型的东西和一个不产生 IO 的东西之间的不匹配。这次,问题出在左箭头 <- 上;我们试图将 makeLoud name 的值左箭头指向,这实际上不是左箭头材料。它基本上与我们在上一节中看到的不匹配相同,只是现在我们试图将普通 String(大声的名称)用作 IO String。后者是一个操作,需要执行,而前者只是一个按部就班的表达式。我们不能简单地使用 loudName = makeLoud name,因为 do操作进行排序,而 loudName = makeLoud name 不是一个操作。

那么我们如何从这个困境中解脱出来呢?我们有很多选择

  • 我们可以找到一种方法将 makeLoud 变成一个操作,使其返回 IO String。但是,我们不想无缘无故地让操作进入世界。在我们的程序中,我们可以可靠地验证一切是如何工作的。当操作与外部世界互动时,我们的结果就不可预测得多。IO makeLoud 会误入歧途。还要考虑另一个问题:如果我们想从其他非 IO 函数中使用 makeLoud 呢?我们真的不想在绝对必要的情况下以外执行 IO 操作。
  • 我们可以使用名为 return 的特殊代码将大声的名称提升为操作,编写类似 loudName <- return (makeLoud name) 的内容。这稍微好一点。我们至少使 makeLoud 函数本身保持整洁,没有 IO,同时以与 IO 兼容的方式使用它。这仍然相当笨拙,因为根据左箭头的规定,我们暗示有操作可行——多么令人兴奋!——只是让我们读者失望于有点反高潮的 return(注意:我们将在后面的章节中学习更多关于 return 的适当用法的知识)。
  • 或者我们可以使用 let 绑定...

事实证明,Haskell 在操作中的 let 绑定有一个特殊的额外方便的语法。它看起来有点像这样

示例:do 块中的 let 绑定。

main =
 do name <- getLine
    let loudName = makeLoud name
    putStrLn ("Hello " ++ loudName ++ "!")
    putStrLn ("Oh boy! Am I excited to meet you, " ++ loudName)

如果您留心观察,您可能会注意到上面的 let 绑定缺少 in。这是因为 do 块内部的 let 绑定不需要 in 关键字。您可以随意使用它,但这样会产生杂乱无章的额外 do 块。就其本身而言,以下两个代码块是等效的。

甜美 不甜
 do name <- getLine
    let loudName = makeLoud name
    putStrLn ("Hello " ++ loudName ++ "!")
    putStrLn (
        "Oh boy! Am I excited to meet you, "
            ++ loudName)
 do name <- getLine
    let loudName = makeLoud name
    in  do putStrLn ("Hello " ++ loudName ++ "!")
           putStrLn (
               "Oh boy! Am I excited to meet you, "
                   ++ loudName)
练习
  1. 为什么不甜版本的 let 绑定需要额外的 do 关键字?
  2. 您是否总是需要额外的 do
  3. (加分题)奇怪的是,没有 inlet 正是我们一开始使用解释器玩耍时的写法。为什么在解释器中可以省略 in 关键字,而在源文件中(除了do 块以外)需要它?

了解更多

[编辑 | 编辑源代码]

此时,您拥有进行更高级的输入/输出所需的基本知识。以下是一些您可能想在与本课程主线平行的过程中查看的 IO 相关主题。

  • 您可以继续按顺序学习,了解更多关于 类型 的知识,并最终学习 单子
  • 或者,您也可以开始学习在 GUI 章节中构建图形用户界面。
  • 有关更多与 IO 相关的功能,您还可以考虑学习更多关于 System.IO 库 的知识。
华夏公益教科书