另一个 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! |