另一个 Haskell 教程/Io
| Haskell | |
|---|---|
| |
| 另一个 Haskell 教程 | |
| 前言 | |
| 介绍 | |
| 入门 | |
| 语言基础 (解决方案) | |
| 类型基础 (解决方案) | |
| IO (解决方案) | |
| 模块 (解决方案) | |
| 高级语言 (解决方案) | |
| 高级类型 (解决方案) | |
| 单子 (解决方案) | |
| 高级 IO | |
| 递归 | |
| 复杂度 | |
正如我们之前提到的,很难找到一种好的、干净的方式将输入/输出操作集成到纯函数式语言中。在我们给出解决方案之前,让我们退一步,思考一下这种任务固有的困难。
任何 IO 库都应该提供许多函数,其中包含(至少)以下操作
- 将字符串打印到屏幕上
- 从键盘读取字符串
- 将数据写入文件
- 从文件读取数据
这里有两个问题。让我们先考虑前两个示例,并思考一下它们的类型应该是什么。当然,第一个操作(我犹豫是否将其称为“函数”)应该接受一个String参数并产生一些东西,但它应该产生什么?它可以产生一个单位(),因为从打印字符串中实际上没有返回值。第二个操作类似地应该返回一个String,但它似乎不需要参数。
我们希望这两个操作都是函数,但它们本质上不是函数。从键盘读取字符串的项目不能是函数,因为它不会每次都返回相同的String。如果第一个函数每次都简单地返回(),那么用函数f _ = ()替换它应该没有问题,因为引用透明性。但很明显,这没有达到预期的效果。
从某种意义上说,这些项目不是函数的原因是它们与“现实世界”交互。它们的值直接依赖于现实世界。假设我们有一个类型RealWorld,我们可以将这些函数写成具有以下类型
printAString :: RealWorld -> String -> RealWorld readAString :: RealWorld -> (RealWorld, String)
也就是说,printAString接受当前的世界状态和要打印的字符串;然后它修改世界状态,使得字符串现在被打印并返回这个新值。类似地,readAString接受当前的世界状态并返回一个新的世界状态,与输入的String配对。
这将是进行 IO 的一种可能方式,尽管它有点笨拙。在这种风格中(假设初始RealWorld状态是main的参数),我们“Name.hs”程序中的交互性部分将如下所示
main rW =
let rW' = printAString rW "Please enter your name: "
(rW'',name) = readAString rW'
in printAString rW''
("Hello, " ++ name ++ ", how are you?")
这不仅难以阅读,而且容易出错,如果你不小心使用了错误版本的RealWorld。它也不模拟以下程序毫无意义的事实
main rW =
let rW' = printAString rW "Please enter your name: "
(rW'',name) = readAString rW'
in printAString rW' -- OOPS!
("Hello, " ++ name ++ ", how are you?")
在这个程序中,最后一行对rW''的引用已更改为对rW'的引用。这个程序应该做什么完全不清楚。显然,它必须读取一个字符串才能有一个name的值来打印。但这意味着RealWorld已经更新。然而,我们试图通过使用RealWorld的“旧版本”来忽略这个更新。这里显然有什么问题。
总之,在纯惰性函数式语言中进行 IO 操作并非易事。
解决这个问题的突破是,Phil Wadler 意识到单子将是思考 IO 计算的一种好方法。实际上,单子能够表达的远不止上面描述的简单操作;我们可以用它们来表达各种结构,例如并发、异常、IO、非确定性等等。此外,它们没有什么特别之处;它们可以在 Haskell 中定义内部,而不需要编译器的特殊处理(尽管编译器通常选择优化单子操作)。
如前所述,我们不能将诸如“将字符串打印到屏幕”或“从文件读取数据”之类的操作视为函数,因为它们不是(在纯粹的数学意义上)。因此,我们给它们另一个名字:动作。我们不仅给它们一个特殊的名称,还给它们一个特殊的类型。一个特别有用的动作是putStrLn,它将字符串打印到屏幕上。这个动作的类型是
putStrLn :: String -> IO ()
如预期的那样,putStrLn接受一个字符串参数。它返回的是IO ()类型。这意味着这个函数实际上是一个动作(这就是IO的意思)。此外,当这个动作被评估(或“运行”)时,结果将具有()类型。
注意
实际上,这个类型意味着putStrLn是在IO 单子内的动作,但我们现在先忽略这一点。
你可能已经猜到getLine的类型了
getLine :: IO String
这意味着getLine是一个 IO 动作,当运行时,将具有String类型。
问题立即出现:“如何'运行'一个动作?”。这由编译器决定。你实际上不能自己运行一个动作;相反,一个程序本身是一个单一动作,在编译后的程序执行时运行。因此,编译器要求main函数具有IO ()类型,这意味着它是一个不返回任何东西的 IO 动作。编译后的代码然后执行这个动作。
但是,虽然你不能自己运行动作,但你可以组合动作。实际上,我们已经看到了一种使用以下方法来实现此目的的方法do符号(如何真正做到这一点将在单子章节中揭示)。让我们考虑最初的姓名程序
main = do
hSetBuffering stdin LineBuffering
putStrLn "Please enter your name: "
name <- getLine
putStrLn ("Hello, " ++ name ++ ", how are you?")
我们可以将do符号视为组合动作序列的一种方式。此外,<-符号是一种从动作中获取值的方式。因此,在这个程序中,我们正在对四个动作进行排序:设置缓冲区、一个putStrLn、一个getLine和另一个putStrLn。putStrLn动作的类型是String -> IO (),所以我们提供给它一个String,因此完全应用的动作的类型是IO ()。这是我们允许执行的。
getLine动作的类型是IO String,因此可以执行它。但是,为了从动作中获取值,我们写name <- getLine,这基本上意味着“运行getLine,并将结果放入名为name的变量中”。
正常的 Haskell 结构,比如if/then/else和case/of可以在do符号内使用,但你需要稍微小心一点。例如,在我们的“猜数字”程序中,我们有
do ...
if (read guess) < num
then do putStrLn "Too low!"
doGuessing num
else if read guess > num
then do putStrLn "Too high!"
doGuessing num
else do 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 (),这正是我们想要的。
注意
在这段代码中,最后一行是else do putStrLn "You Win!"。这有点过于冗长。实际上,else putStrLn "You Win!"就足够了,因为do只需要对动作进行排序。由于我们这里只有一个动作,所以它是多余的。
认为“好吧,我已经开始了一个do块;我不需要另一个,”并因此写下类似以下内容是错误的
do if (read guess) < num
then putStrLn "Too low!"
doGuessing num
else ...
这里,因为我们没有重复do,编译器不知道putStrLn和doGuessing调用应该被排序,编译器会认为你试图用三个参数调用putStrLn:字符串、函数doGuessing和整数num。它肯定会抱怨(尽管错误可能在此时有点难以理解)。
我们可以使用case语句来编写相同的doGuessing函数。为此,我们首先引入 Prelude 函数compare,它接受相同类型的两个值(在Ord类中),并根据第一个值是否大于、小于或等于第二个值返回GT、LT、EQ之一。
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
LT -> do putStrLn "Too low!"
doGuessing num
GT -> do putStrLn "Too high!"
doGuessing num
EQ -> putStrLn "You Win!"
这里,->后的dos在第一个和第二个选项中是必需的,因为我们正在对动作进行排序。
如果你习惯于使用像 C 或 Java 这样的命令式语言进行编程,你可能会认为return将退出你当前的函数。在 Haskell 中并非如此。在 Haskell 中,return它只是将一个普通的值(例如,类型为Int的值)转换为一个返回该值的动作(例如,类型为IO Int的值)。具体来说,在命令式语言中,你可能会这样编写此函数:
void doGuessing(int num) {
print "Enter your guess:";
int guess = atoi(readLine());
if (guess == num) {
print "You win!";
return ();
}
// we won't get here if guess == num
if (guess < num) {
print "Too low!";
doGuessing(num);
} else {
print "Too high!";
doGuessing(num);
}
}
这里,因为我们在第一个if匹配中使用了return (),所以我们期望代码在那里退出(在大多数命令式语言中,它确实如此)。然而,在 Haskell 中等效的代码,看起来可能像这样
doGuessing num = do
putStrLn "Enter your guess:"
guess <- getLine
case compare (read guess) num of
EQ -> do putStrLn "You win!"
return ()
-- we don't expect to get here unless guess == num
if (read guess < num)
then do putStrLn "Too low!";
doGuessing num
else do putStrLn "Too high!";
doGuessing num
不会按你预期的方式运行。首先,如果你猜对了,它会先打印“你赢了!”,但它不会退出,并且会检查guess是否小于num。当然它不是,所以执行else分支,它会打印“太高了!”,然后要求你再次猜测。
另一方面,如果你猜错了,它会尝试计算case语句并获得compare结果的LT或GT。在这两种情况下,它都没有匹配的模式,程序将立即因异常而失败。
| 练习 |
|---|
|
编写一个程序,要求用户输入其姓名。如果姓名是 Simon、John 或 Phil 之一,告诉用户你认为 Haskell 是一种很棒的编程语言。如果姓名是 Koen,告诉他们你认为调试 Haskell 很有趣(Koen Classen 是 Haskell 调试工作者之一);否则,告诉用户你不认识他或她。 编写此程序的两个不同版本,一个使用if 语句,另一个使用case语句。 |
IO 库(通过import导入System.IO模块来使用)包含许多定义,其中最常见的一些列在下面
data IOMode = ReadMode | WriteMode
| AppendMode | ReadWriteMode
openFile :: FilePath -> IOMode -> IO Handle
hClose :: Handle -> IO ()
hIsEOF :: Handle -> IO Bool
hGetChar :: Handle -> IO Char
hGetLine :: Handle -> IO String
hGetContents :: Handle -> IO String
getChar :: IO Char
getLine :: IO String
getContents :: IO String
hPutChar :: Handle -> Char -> IO ()
hPutStr :: Handle -> String -> IO ()
hPutStrLn :: Handle -> String -> IO ()
putChar :: Char -> IO ()
putStr :: String -> IO ()
putStrLn :: String -> IO ()
readFile :: FilePath -> IO String
writeFile :: FilePath -> String -> IO ()
bracket ::
IO a -> (a -> IO b) -> (a -> IO c) -> IO c
注意
类型FilePath是String的类型别名。也就是说,FilePath和String之间没有区别。因此,例如,readFile函数接收一个String(要读取的文件)并返回一个动作,该动作在运行时生成该文件的內容。有关类型别名的更多信息,请参见关于别名的部分。
这些函数中的大多数不言自明。openFile和hClose函数分别使用IOMode参数作为打开文件的模式,打开和关闭文件。hIsEOF测试文件结束符。hGetChar和hGetLine分别从文件中读取一个字符或一行。hGetContents读取整个文件。getChar、getLine和getContents变体从标准输入读取。hPutChar将一个字符打印到文件;hPutStr打印一个字符串;hPutStrLn打印一个字符串,并在末尾添加一个换行符。没有h前缀的变体作用于标准输出。readFile和writeFile函数读取整个文件,无需先打开它。
bracket函数用于安全地执行操作。考虑一个打开文件、向其中写入一个字符,然后关闭文件的函数。在编写此类函数时,需要小心确保如果在某个点出现错误,文件仍能成功关闭。bracket函数简化了这一过程。它接收三个参数:第一个是要在开始时执行的操作。第二个是在结束时执行的操作,无论是否出现错误。第三个是在中间执行的操作,该操作可能会导致错误。例如,我们的字符写入函数可能如下所示:
writeChar :: FilePath -> Char -> IO ()
writeChar fp c =
bracket
(openFile fp ReadMode)
hClose
(\h -> hPutChar h c)
这将打开文件、写入字符,然后关闭文件。但是,如果写入字符失败,hClose仍然会执行,并且异常将在之后重新抛出。这样,你就不必过于担心捕获异常和关闭所有句柄。
我们可以编写一个简单的程序,允许用户读取和写入文件。该接口承认很差,并且没有捕获所有错误(尝试读取一个不存在的文件)。然而,它应该提供一个关于如何使用 IO 的相当完整的示例。将以下代码输入“FileRead.hs”,并编译/运行
module Main
where
import System.IO
import Control.Exception
main = do
hSetBuffering stdin LineBuffering
doLoop
doLoop = do
putStrLn "Enter a command rFN wFN or q to quit:"
command <- getLine
case command of
'q':_ -> return ()
'r':filename -> do putStrLn ("Reading " ++ filename)
doRead filename
doLoop
'w':filename -> do putStrLn ("Writing " ++ filename)
doWrite filename
doLoop
_ -> doLoop
doRead filename =
bracket (openFile filename ReadMode) hClose
(\h -> do contents <- hGetContents h
putStrLn "The first 100 chars:"
putStrLn (take 100 contents))
doWrite filename = do
putStrLn "Enter text to go into the file:"
contents <- getLine
bracket (openFile filename WriteMode) hClose
(\h -> hPutStrLn h contents)
此程序的功能是什么?首先,它发出一个简短的指令字符串并读取一个命令。然后它执行case对命令进行switch操作,并首先检查第一个字符是否为`q'。如果是,它将返回一个单位类型的值。
注意
return函数是一个接收类型为a的值并返回类型为IO a的动作的函数。因此,return ()的类型为IO ()。
如果命令的第一个字符不是`q',程序会检查它是否为`r'后跟一些绑定到变量filename的字符串。然后它会告诉你它正在读取文件,执行读取操作并再次运行doLoop。对`w'的检查几乎相同。否则,它匹配_(通配符),并循环到doLoop。
doRead函数使用bracket函数来确保在读取文件时没有问题。它以ReadMode打开一个文件,读取其内容并打印前 100 个字符(take函数接收一个整数和一个列表,并返回列表的前个元素)。
doWrite函数请求一些文本,从键盘读取文本,然后将其写入指定的文件。
注意
doRead和doWrite本可以更简单,使用readFile和writeFile,但它们是以扩展方式编写的,以显示如何使用更复杂的函数。
此程序的唯一主要问题是,如果你尝试读取一个不存在的文件,或者指定了一些错误的文件名,例如*\^\#_@,它将崩溃。你可能会认为doRead和doWrite中的bracket调用应该解决这个问题,但事实并非如此。它们只捕获主体内出现的异常,而不是启动或关闭函数(在本例中为openFile和hClose)内出现的异常。我们需要捕获openFile引发的异常,以使其完整。当我们在关于异常的部分中详细讨论异常时,我们将做到这一点。
| 练习 |
|---|
|
编写一个程序,首先询问用户是否要从文件读取、写入文件还是退出。如果用户响应退出,程序应该退出。如果他响应读取,程序应该询问他一个文件名并将其打印到屏幕上(如果文件不存在,程序可能会崩溃)。如果他响应写入,它应该询问他一个文件名,然后询问他要写入文件的文本,以"."表示完成。除"."以外的所有内容都应写入文件。 例如,运行此程序可能会产生以下结果: 示例 Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foo ...contents of foo... Do you want to [read] a file, [write] a file or [quit]? write Enter a file name to write: foo Enter text (dot on a line by itself to end): this is some text for foo . Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foo this is some text for foo Do you want to [read] a file, [write] a file or [quit]? read Enter a file name to read: foof Sorry, that file does not exist. Do you want to [read] a file, [write] a file or [quit]? blech I don't understand the command blech. Do you want to [read] a file, [write] a file or [quit]? quit Goodbye! |
