跳转到内容

另一个 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 的变量中”。

if/then/elsecase/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

在这里,我们正在按顺序执行两个操作: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,编译器不知道 `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!
华夏公益教科书