Haskell/简单输入输出
除了内部计算值,我们希望我们的程序能够与世界交互。任何语言中最常见的初学者程序只是在屏幕上显示一个“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 语法提供了一种方便的方法来将操作组合在一起(这对于使用 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
这里,我们正在对两个操作进行排序:putStrLn
和 doGuessing
。第一个具有 IO ()
类型,这很好。第二个也具有 IO ()
类型,这很好。整个计算的类型结果恰好是最终计算的类型。因此,“then”分支的类型也是 IO ()
。类似的论证表明“else”分支的类型也是 IO ()
。这意味着整个if/then/else结构的类型为 IO ()
,这正是我们想要的。
注意:如果您发现自己这样想,请小心,“好吧,我已经开始了一个do块;我不需要另一个。”我们不能有这样的代码
do if (read guess) < num
then putStrLn "Too low!"
doGuessing num
else ...
这里,因为我们没有重复do,编译器不知道 putStrLn
和 doGuessing
调用应该被排序,编译器会认为您正在尝试使用三个参数调用 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
。但是,我们不想无缘无故地让操作进入世界。在我们的程序中,我们可以可靠地验证一切是如何工作的。当操作与外部世界互动时,我们的结果就不可预测得多。IOmakeLoud
会误入歧途。还要考虑另一个问题:如果我们想从其他非 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)
|
练习 |
---|
|
此时,您拥有进行更高级的输入/输出所需的基本知识。以下是一些您可能想在与本课程主线平行的过程中查看的 IO 相关主题。
- 您可以继续按顺序学习,了解更多关于 类型 的知识,并最终学习 单子。
- 或者,您也可以开始学习在 GUI 章节中构建图形用户界面。
- 有关更多与 IO 相关的功能,您还可以考虑学习更多关于 System.IO 库 的知识。