整理 Haskell 中的 I/O 行为和性质,包括惰性 I/O、异常、临时文件、缓冲等。
基本 I/O 行为
标准输入/输出
Prelude
定义了一些标准输入、输出及其函数:print
:将任何可打印的值输出到标准输出设备putStr
:向标准输出输出字符串putStrLn
:向标准输出输出字符串并添加换行getLine
:从标准输入中读取一行interact
:其类型签名为interact :: (String -> String) -> IO()
,该函数接受一个签名为String -> String
的函数,对标准输入中的每个字符串做转换并输出。默认情况下 GHCI 采用 LineBuffering 模式,也就是每输入一行或输入足够长的情况下才产生一次回显
- 标准输入/输出实际是
System.IO
预定义的一些句柄,以上的getLine
、putStr
等函数实际是由其句柄函数封装得来的:stdin
:getLine = hGetLine stdin
stdout
:putStrLn = hPutStrLn stdout
stderr
:print = 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 包裹的句柄,其中IOMode
与openFile
中相同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
:获取一个链接到终端的句柄的回显状态- 使用
openFile
、writeFile
可处理文本文件,通常可简化为以下三种操作,其中FilePath
是String
的别名: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 操作。 mapM
和mapM_
提供了在列表上应用 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 类系统中,可使用
putEnv
或setEnv
(包含在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
仅当它写数据时才有效。当传输大文本文件时,你可以通过readFile
和writeFile
建立一个类似管道,内存使用不会很高,并且会很稳定。
1 | -- This example is modified |
- 注意:你不能在使用 I/O 动作返回的结果之前就关闭句柄,例如上面的例子,如果
hPutStr outh inpStr
和hClose inh
交换顺序,则程序崩溃。因为 Haskell 的惰性求值,在hPutStr
执行前,inpStr
并未真正读入内存。 - 惰性 I/O 可能导致副作用:I/O 操作返回的
String
将被纯粹的代码使用,但纯粹的代码并不知道这部分String
是 I/O 行为的结果,因此当纯粹代码使用它的时候,可能会导致实际数据的读写。因此当需要和用户输入交互、或者输入数据随时间变化时,hGetContents
可能并不合适。
原创作品,允许转载,转载时无需告知,但请务必以超链接形式标明文章原始出处(http://blog.forec.cn/2016/11/21/haskell-io-actions/) 、作者信息(Forec)和本声明。