文章
Get Programming with Haskell-21
在阅读完第 21 课后,你将能够: • 理解 Haskell 如何通过使用 IO 类型处理 I/O。 • 使用 do 记法执行 I/O 操作。 • 编写能够与现实世界交互的纯粹程序。
目录
IO in Haskell#
到目前为止,本书中你已经看到了许多使用 Haskell 可以完成的强大功能的示例。Haskell 的一个反复出现的主题是,这种强大功能很大程度上源于诸如引用透明性和 Haskell 的类型系统之类的简单事物。但是,到目前为止,一直存在一个明显的遗漏:I/O。
无论你的程序做什么,无论它用什么语言编写,I/O 都是软件中极其重要的一部分。它是你的代码与现实世界相遇的点。那么,为什么你还没有看到太多涉及 I/O 的 Haskell 代码呢?问题在于,使用 I/O 本质上需要你改变世界。例如,从命令行获取用户输入。每次你有一个请求用户输入的程序时,你都期望结果是不同的。但是在第一单元中,我们花费了大量时间讨论所有函数都接受一个参数、返回一个值并且对于相同的参数总是返回相同的值的重要性。I/O 的另一个问题是你总是在改变世界,这意味着你正在处理状态。如果你读取一个文件并写入另一个文件,如果你的程序在某个地方不改变世界,那么它们将毫无用处。但是,再次强调,避免状态是第一单元中讨论的 Haskell 的关键优点之一。
那么 Haskell 如何解决这个问题呢?正如你可能预料的那样,Haskell 通过使用类型来做到这一点。
Haskell 有一个特殊的参数化类型叫做 IO。任何在 IO 上下文中的值都必须保持在这个上下文中。这可以防止纯代码(意味着它保持引用透明并且不改变状态)和必要的非纯代码混合在一起。
为了演示这一点,你将比较Java和Haskell中两个类似的神秘函数。你将首先看看两个几乎相同的 Java 方法,称为 mystery1 和 mystery2。
public class Example {
public static int mystery1(int val1, int val2){
int val3 = 3;
return Math.pow(val1 + val2 + val3, 2);
}
public static int mystery2(int val1, int val2){
int val3 = 3;
System.out.print("Enter a number");
try {
Scanner in = new Scanner(System.in);
val3 = in.nextInt();
} catch (IOException e) {
e.printStackTrace();
}
return Math.pow(val1 + val2 + val3,2);
}
}
这里有两个静态方法,mystery1 和 mystery2。两者都做同样的事情:它们接收两个值,将它们与一个神秘值相加,然后对结果进行平方。最重要的是,这些方法在 Java 中具有相同的类型签名。但我认为没有人会认为这些方法是完全相同的!
mystery1 方法是可预测的。每次你输入两个值,你都会得到完全相同的输出。用 Haskell 的术语来说,mystery1 是一个纯函数。如果你对这个函数进行足够的尝试,你最终就能弄清楚它做了什么。
另一方面,mystery2 方法是不同的。每次你调用 mystery2 时,都可能发生很多错误。此外,每次你调用 mystery2 时,你很可能会得到不同的答案。你可能永远无法弄清楚 mystery2 在做什么。现在,在这个例子中,你可以清楚地看出区别,因为 mystery2 会强制显示一个命令提示符。但是,假设 mystery2 只是从磁盘上的一个随机文件中读取数据。你可能永远不知道它在做什么。神秘函数的想法可能看起来是人为设计的,但是任何时候你使用遗留代码或外部库时,你经常会遇到神秘函数:你可能很容易从它们的行为中理解它们,但是无法知道它们在做什么。
Haskell 通过强制这两个函数具有不同的类型来解决这个问题。每当一个函数使用 IO 时,该函数的结果永远被标记为来自 IO。以下是将这两个 Java 方法重写为 Haskell 函数的形式。
mystery1 :: Int -> Int -> Int
mystery1 val1 val2 = (val1 + val2 + val3)^2 where val3 = 3
mystery2 :: Int -> Int -> IO Int
mystery2 val1 val2 = do
putStrLn "Enter a number"
val3Input <- getLine
let val3 = read val3Input
return ((val1 + val2 + val3)^2)
为什么这个 IO 类型使你的代码更安全?IO 使得在其他纯函数中意外使用被 I/O“污染”的值成为不可能。例如,加法是一个纯函数,所以你可以将两次调用 mystery1 的结果相加:
safeValue = (mystery1 2 4) + (mystery1 5 6)
但是如果你尝试做同样的事情,你会得到一个编译器错误:
unsafeValue = (mystery2 2 4) + (mystery2 2 4)
"No instance for (Num (IO Int)) arising from a use of '+'"
尽管这无疑增加了程序的安全性,但你到底要如何做事呢?在本单元中,你将专注于学习 Haskell 的工具,这些工具使你能够将纯代码与 I/O 代码分开,并且仍然可以编写与现实世界交互的有用程序。在本单元之后,你将能够将 Haskell 用于各种涉及使用 I/O 的日常、现实世界的编程问题。
Lesson 21 HELLO WORLD!—介绍 IO 类型#
在第 1 课中,你看到了一个基本的 Hello World 程序的示例。在本课中,你将重新回顾一个类似的程序,以便更好地理解 Haskell 中的 I/O 工作方式。以下是一个使用 I/O 的示例程序,它从命令行读取一个名字并打印出 "Hello <name>!"。 Haskell
helloPerson :: String -> String
helloPerson name = "Hello" ++ " " ++ name ++ "!"
main :: IO ()
main = do
putStrLn "Hello! What's your name?"
name <- getLine
let statement = helloPerson name
putStrLn statement
在你学习任何 Haskell 之前,你可能已经能够很好地理解这个程序。不幸的是,现在你对 Haskell 了解得更多了,这看起来可能更加令人困惑!helloPerson 函数应该很简单,但是从 main 开始的所有内容都与你目前所见过的任何其他内容不同。你可能会有以下疑问:
IO ()到底是什么类型?- 为什么
main后面有一个do? putStrLn是否返回一个值?- 为什么有些变量用
<-赋值,而另一些用let赋值? 在本课结束时,你将对这些问题中的每一个都有合理的解释,并且希望对 Haskell 中 I/O 的基础知识有更好的理解。