Haskell 中的非纯粹行为

整理 Haskell 中的 I/O 行为和性质,包括惰性 I/O、异常、临时文件、缓冲等。

基本 I/O 行为

标准输入/输出

  • Prelude 定义了一些标准输入、输出及其函数:
    • print:将任何可打印的值输出到标准输出设备
    • putStr:向标准输出输出字符串
    • putStrLn:向标准输出输出字符串并添加换行
    • getLine:从标准输入中读取一行
    • interact:其类型签名为 interact :: (String -> String) -> IO(),该函数接受一个签名为 String -> String 的函数,对标准输入中的每个字符串做转换并输出。默认情况下 GHCI 采用 LineBuffering 模式,也就是每输入一行或输入足够长的情况下才产生一次回显
  • 标准输入/输出实际是 System.IO 预定义的一些句柄,以上的getLineputStr等函数实际是由其句柄函数封装得来的:
    • stdingetLine = hGetLine stdin
    • stdoutputStrLn = hPutStrLn stdout
    • stderrprint = hPrint stdout

句柄和简化的文本文件读写

System.IO 包中包含了多数 I/O 相关操作,其中大多数操作已被 Prelude 导入。Handle 类型是文件句柄,Haskell 从句柄读写数据。

  • openFile :: FilePath -> IOMode -> IO Handle:以文本方式打开一个文件并返回 IO Monad 包裹的句柄,其中 IOMode 包括:
    • ReadMode:只读
    • WriteMode:只写
    • ReadWriteMode:读写
    • AppendMode:追加
  • openBinaryFile :: FilePath -> IOMode -> IO Handle:以二进制方式打开一个文件并返回 IO Monad 包裹的句柄,其中 IOModeopenFile 中相同
  • hIsEOF :: Handle -> IO Bool:是否已读取到句柄末尾
  • hGetLine :: Handle -> IO String:从句柄中读取一行,此函数应只在文本文件的句柄中使用
  • hPutStrLn :: Handle -> String -> IO ():向句柄中写入字符串并添加换行
  • hPutStr :: Handle -> String -> IO ():向句柄中写入字符串
  • hPrint :: Show a => Handle -> a -> IO ():向句柄中写入可打印值(以上四个函数实际是标准输入/输出函数的内部实现)。注意此函数和 putStr 不同,此函数可打印任何实现了 Show 的实例的值,例如 hPrint stdout "haskell",则标准输出为 "haskell",而 hPutStr stdout "haskell" 在标准输出产生的是 haskell
  • hClose :: Handle -> IO ():关闭句柄
  • hTell :: Handle -> IO Integer:返回此句柄目前对应文件位置
  • hSeek :: Handle -> SeekMode -> Integer -> IO ():按 SeekMode 模式设置句柄位置,其中 SeekMode 包括:
    • AbsoluteSeek:绝对定位,按照给定的 Integer 参数设置句柄位置
    • RelativeSeek:相对定位,相对当前句柄位置按给定参数修正
    • SeekFromEnd:从文件尾部定位,与 AbsoluteSeek 方向相反
  • hIsSeekable :: Handle -> IO Bool:返回该句柄是否可以定位
  • hGetPosn :: Handle -> IO HandlePosn:返回该句柄位置
  • hGetChar :: Handle -> IO Char:从句柄读取一个字符
  • hGetContents :: Handle -> IO String:读取句柄全部内容
  • hGetEcho :: Handle -> IO Bool:获取一个链接到终端的句柄的回显状态
  • 使用 openFilewriteFile 可处理文本文件,通常可简化为以下三种操作,其中 FilePathString 的别名:
    • readFile :: FilePath -> IO String:接受一个 FilePath 作为文件名,打开该文件(如果存在的话)并以字符串返回文件内容
    • writeFile :: FilePath -> String -> IO():接受一个 FilePath 作为文件名,向该文件写入字符串(如果文件不存在则创建,否则覆盖原文件)
    • appendFile :: FilePath -> String -> IO ():接受一个 FilePath 作为文件名,并想该文件附加字符串(无论文件是否存在都会写入)

序列化和语法糖

  • do 语法糖将执行非纯粹行为的代码包裹起来,其中:
    • 整个代码块返回的值是代码块最后一行语句返回的值
    • 在代码块中使用 <- 从 I/O 行为中获取值
    • 在代码块中使用 let 从纯粹代码中获取值,注意不是 let..in
    • do 代码块返回的值与 return 函数无关,return 仅仅是将纯粹的值使用 Monad 包裹,与命令式语言中的 return 没有任何联系。在 I/O 操作中,return 的作用就是将纯粹值包裹到 IO Monad 中。
  • 你可以假设,do 代码块中的每个语句(除了 let),都会产生一个待执行的 I/O 操作。
  • mapMmapM_ 提供了在列表上应用 Monad 的方式,其中 mapM 返回应用后的列表,而 mapM_ 丢弃结果。
    • mapM :: (Monad m) => (a -> m b) -> [a] -> m [b]
    • mapM_ :: (Monad m) => (a -> m b) -> [a] -> m ()
  • 以下两个函数将 Monad 操作连接起来,当 Monad 为 IO Monad 时即为 do 的内部实现
    • (>>) :: (Monad m) => m a -> m b -> m b:该函数连接两个 Monad 操作,首先执行第一个,之后执行第二个,返回是第二个 Monad 操作返回的值
    • (>>=) :: (Monad m) => m a -> (a -> m b) -> m b:该函数执行第一个 Monad 操作,将其返回的结果传给第二个参数,第二个参数接受第一个 Monad 操作的结果,并返回另一个 Monad。例如 getLine >>= putStrLn 作用就是从键盘读取一行,再输出到屏幕。

文件操作和临时文件

  • 涉及非内容的文件操作相关函数包含在 System.Directory 中,常见如:
    • removeFile :: FilePath -> IO():删除参数名指向的文件
    • renameFile :: FilePath -> FilePath -> IO():重命名文件,可等同于 mv 操作,但若第二个参数(重命名后的文件名)对应文件已存在,则该文件被要移动的文件覆盖。使用该操作应小心。
    • renameDirectory :: FilePath -> FilePath -> IO():重命名文件夹
    • getTemporaryDirectory :: IO FilePath:获取当前机器的临时文件目录。有些机器并不存在默认的缓存目录,此时调用此函数将导致异常。因此此函数应当被 catchIOError 包裹。例如,catchIOError (getTemporaryDirectory) (\_->return ".")
  • 临时文件主要使用 openTempFile :: FilePath -> String -> IO(FilePath, Handle) 创建,对于二进制临时文件则使用 openBinaryTempFile。该函数第一个参数为要创建临时文件的目录,第二个参数为临时文件的前缀,例如 tempfile,则 Haskell 生成的临时文件会有类似 tempfileXXXXXX 的文件名,后面的 XXXXXX 为随机生成的序列。该函数会返回 IO Monad 包裹的路径和句柄。
  • 在临时文件试用结束后,通常会通过 hClose 关闭临时文件句柄,之后 removeFile 删除临时文件。此部分处理工作应由 finally 包含以保证执行。

I/O 异常捕获处理

  • I/O 操作的异常捕获通过 catchIOError 执行,该函数包含在 System.IO.Error 中,签名为 catchIOError:: IO a-> (IOError -> IO a) -> IO a。该函数接收两个 IO Monad 作为参数,如果第一个函数执行时产生异常,则执行第二个函数。
  • 一个更通用的异常捕获函数是 catch,包含在 Control.Exception 中,可捕获任意类型 IO 异常。如果你使用 catch 则必须指定异常的类型,否则编译器会汇报模糊的定义错误。该函数类型签名为 catch:: Exception e => IO a -> (e -> IO a) -> IO a,第二个参数是一个函数,这个函数接收异常并执行第二个 Monad (即 catchIOError 中捕获到异常后执行的 IO Monad)。这个函数必须指定错误 e 的类型,如果要涵盖所有错误,可以使用 e :: SomeException 来泛解析。
  • 我们使用 finally 来确保一个 IO 操作的执行,其类型签名为 finally:: IO a-> IO b -> IO a,无论第一个 IO Monad 成功还是失败,第二个 IO Monad 都将执行,且整个代码块返回值为第一个 IO 行为的返回值。

缓冲模式和命令行参数

  • I/O 行为有如下几种缓冲模式:
    • NoBuffering:不使用缓冲,按字符逐个读取/写入
    • LineBuffering:使用行缓冲,当一个换行符被输出或整个缓冲区已经足够长时,输出缓冲才被写入。在交互式终端中,一旦输入一个回车,则缓冲区将立刻被写入/读取。
    • BlockBuffering:块缓冲,在可能的情况下按固定大小的块读取/写入,在读写大数据的情况下性能更好,但它将阻塞输入(无法获得回显)直到块足够大。它的值构造器接受一个 Maybe 类型作为参数,如果是 Nothing 则使用预定义的缓冲区大小,否则如果输入是 Just 1024,则使用 1024 字节缓冲区。
  • 使用 hGetBuffering Handle 可以获得参数句柄的缓冲模式。
  • 使用 hSetBuffering Handle BufferMode 可以设置句柄的缓冲模式,例如 hSetBuffering stdin (BlockBuffering Nothing)
  • 命令行参数的读取可使用 System.Environment 中定义的函数:
    • getArgs 返回 IO [String],包含了命令行参数的列表,和 C 语言中的 argv 类似,这个列表的第一个元素是程序名,之后为其它参数。
    • 程序名称可通过 getProgName :: IO String 获得。
    • 指定的环境变量可通过 getEnv :: String -> IO String 获得,返回 String 参数键对应的值。
    • 全部的环境变量可通过 getEnvironment 获得,其返回值签名为 [(String, String)]
    • 在 POSIX 类系统中,可使用 putEnvsetEnv(包含在 System.Posix.Env 模块中) 设置环境变量。但该操作不跨平台,在 Windows 中不存在设置环境变量的方法。
  • System.Console.GetOpt 模块包含了更多处理命令行参数的函数。

惰性 I/O

  • 在 Haskell 中,I/O 操作同样保持惰性。例如,hGetContents 会从一个文件中获取全部内容,返回 IO String。这里返回的 String 就将被惰性求值。无论是读取一个 500 GB 的大文件还是一个 2 KB 的小文件,hGetContents 均不会将文件读入内存。只有当真正使用到其返回的 String 时,这个读取动作才开始执行。
  • 我们通常认为,只有 输出了对 I/O 返回值计算后的结果 才算使用到了其返回值。
  • hGetContents 返回的 String 在代码中不再被引用时,Haskell 的垃圾回收器将自动释放该部分内存。
  • 惰性 I/O 的体现可以通过下面的代码看出:hPutStr并不是将整个 inpStr 读入内存,inpStr 仅当它写数据时才有效。当传输大文本文件时,你可以通过 readFilewriteFile 建立一个类似管道,内存使用不会很高,并且会很稳定。
1
2
3
4
5
6
7
8
9
-- This example is modified
-- Its sources from 《Real World Haskell》
main = do
inh <- openFile "input.txt" ReadMode
outh <- openFile "output.txt" WriteMode
inpStr <- hGetContents inh
hPutStr outh inpStr
hClose inh
hClose outh
  • 注意:你不能在使用 I/O 动作返回的结果之前就关闭句柄,例如上面的例子,如果 hPutStr outh inpStrhClose inh 交换顺序,则程序崩溃。因为 Haskell 的惰性求值,在 hPutStr 执行前,inpStr 并未真正读入内存。
  • 惰性 I/O 可能导致副作用:I/O 操作返回的 String 将被纯粹的代码使用,但纯粹的代码并不知道这部分 String 是 I/O 行为的结果,因此当纯粹代码使用它的时候,可能会导致实际数据的读写。因此当需要和用户输入交互、或者输入数据随时间变化时,hGetContents 可能并不合适。

原创作品,允许转载,转载时无需告知,但请务必以超链接形式标明文章原始出处(http://blog.forec.cn/2016/11/21/haskell-io-actions/) 、作者信息(Forec)和本声明。

分享到