跳转到内容

另一个 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和另一个putStrLnputStrLn动作的类型是String -> IO (),所以我们提供给它一个String,因此完全应用的动作的类型是IO ()。这是我们允许执行的。

getLine动作的类型是IO String,因此可以执行它。但是,为了从动作中获取值,我们写name <- getLine,这基本上意味着“运行getLine,并将结果放入名为name的变量中”。

正常的 Haskell 结构,比如if/then/elsecase/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

这里,我们正在对两个动作进行排序:putStrLndoGuessing。第一个的类型是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,编译器不知道putStrLndoGuessing调用应该被排序,编译器会认为你试图用三个参数调用putStrLn:字符串、函数doGuessing和整数num。它肯定会抱怨(尽管错误可能在此时有点难以理解)。

我们可以使用case语句来编写相同的doGuessing函数。为此,我们首先引入 Prelude 函数compare,它接受相同类型的两个值(在Ord类中),并根据第一个值是否大于、小于或等于第二个值返回GTLTEQ之一。

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结果的LTGT。在这两种情况下,它都没有匹配的模式,程序将立即因异常而失败。

练习

编写一个程序,要求用户输入其姓名。如果姓名是 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

注意

类型FilePathString类型别名。也就是说,FilePathString之间没有区别。因此,例如,readFile函数接收一个String(要读取的文件)并返回一个动作,该动作在运行时生成该文件的內容。有关类型别名的更多信息,请参见关于别名的部分。

这些函数中的大多数不言自明。openFilehClose函数分别使用IOMode参数作为打开文件的模式,打开和关闭文件。hIsEOF测试文件结束符。hGetCharhGetLine分别从文件中读取一个字符或一行。hGetContents读取整个文件。getChargetLinegetContents变体从标准输入读取。hPutChar将一个字符打印到文件;hPutStr打印一个字符串;hPutStrLn打印一个字符串,并在末尾添加一个换行符。没有h前缀的变体作用于标准输出。readFilewriteFile函数读取整个文件,无需先打开它。

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函数请求一些文本,从键盘读取文本,然后将其写入指定的文件。

注意

doReaddoWrite本可以更简单,使用readFilewriteFile,但它们是以扩展方式编写的,以显示如何使用更复杂的函数。

此程序的唯一主要问题是,如果你尝试读取一个不存在的文件,或者指定了一些错误的文件名,例如*\^\#_@,它将崩溃。你可能会认为doReaddoWrite中的bracket调用应该解决这个问题,但事实并非如此。它们只捕获主体内出现的异常,而不是启动或关闭函数(在本例中为openFilehClose)内出现的异常。我们需要捕获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!
华夏公益教科书