整理 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 stdinstdout:putStrLn = hPutStrLn stdoutstderr: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)和本声明。