另一个 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
的变量中”。
像if/then/else和case/of这样的普通 Haskell 结构可以在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 库
[edit | edit source]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` 仍然会被执行,并且异常将在之后重新抛出。这样,您就不必太担心捕获异常和关闭所有句柄。
文件读取程序
[edit | edit source]我们可以编写一个简单的程序,允许用户读取和写入文件。该接口的承认很差,它并没有捕获所有错误(尝试读取一个不存在的文件)。然而,它应该提供一个相当完整的关于如何使用 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)
这个程序做了什么?首先,它发出简短的指令字符串并读取命令。然后它对命令执行caseswitch,并首先检查第一个字符是否为 `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! |