第 14 章 - 文件与异常(Files and exceptions)
本章内容包括:
- 使用
System.IO以更细粒度的方式读写文件 - 理解
Handle、缓冲、定位和二进制读取 - 使用
bracket保证资源被正确释放 - 遍历文件系统,并用
System.Directory与System.FilePath组合路径 - 在
IO中抛出、捕获和处理异常
前几章中,我们已经多次读取文件:有时读取文本行,有时读取 CSV,有时读取图像字节。那些例子大多使用高层函数,例如 readFile 或库提供的解析入口。高层函数很方便,但真实应用通常需要更细的控制:文件可能很大,可能是二进制格式,可能需要从中间位置读取,可能还要在失败时清理已经打开的资源。
本章会补上这部分知识。我们会从文件句柄开始,学习如何明确打开、读取、移动和关闭文件。接着会讨论二进制数据和 ByteString,因为很多实际格式都不应该用 String 表示。最后,我们会把文件操作与异常处理结合起来,写出即使遇到不存在的路径、权限错误或读取失败,也能保持可预测行为的程序。
14.1 打开和读取文件(Opening and reading files)
readFile 的类型很简单:
readFile :: FilePath -> IO String
它隐藏了打开文件、读取内容和关闭文件的细节。对于小文件和简单脚本来说,这很好。但如果想逐行处理、改变缓冲策略、读取二进制数据,或在同一个文件中跳转位置,就需要使用更底层的接口。
这些接口位于 System.IO 模块中。它们的核心概念是 Handle。
14.1.1 System.IO 与 Handle
Handle 可以理解为 Haskell 程序与外部资源之间的一张票据。文件存在于操作系统中,而不是 Haskell 的纯世界里。程序打开文件后,运行时会返回一个 Handle,之后的读写操作都通过它完成。
代码清单 14.1 打开文件并逐行读取
module Main (main) where
import System.IO
printLines :: Handle -> IO ()
printLines handle = do
done <- hIsEOF handle -- #1
if done
then pure () -- #2
else do
line <- hGetLine handle -- #3
putStrLn line
printLines handle -- #4
main :: IO ()
main = do
handle <- openFile "notes.txt" ReadMode -- #5
printLines handle
hClose handle -- #6
- #1 检查是否已经到达文件末尾
- #2 没有更多内容时停止递归
- #3 从句柄中读取一行
- #4 继续读取剩余行
- #5 以只读模式打开文件
- #6 读取完成后关闭句柄
openFile 的类型如下:
openFile :: FilePath -> IOMode -> IO Handle
IOMode 描述文件打开方式:
data IOMode
= ReadMode
| WriteMode
| AppendMode
| ReadWriteMode
这个类型让意图非常明确。ReadMode 用于读取,WriteMode 会覆盖已有文件,AppendMode 会把新内容追加到末尾,ReadWriteMode 允许同时读取和写入。
代码清单 14.1 有一个重要问题:如果 printLines 中间发生异常,hClose 就不会执行。比如文件被外部删除、终端输出失败,或程序被其他异常打断时,句柄可能保持打开状态。稍后会用 bracket 解决这个问题。先看一个更方便但仍然安全的高层函数:withFile。
代码清单 14.2 使用 withFile 自动关闭句柄
module Main (main) where
import System.IO
printLines :: Handle -> IO ()
printLines handle = do
done <- hIsEOF handle
if done
then pure ()
else hGetLine handle >>= putStrLn >> printLines handle
main :: IO ()
main =
withFile "notes.txt" ReadMode printLines -- #1
- #1 打开文件,将句柄传给
printLines,并在动作结束后关闭文件
withFile 的类型很能说明它的用途:
withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
它接收一个使用句柄的动作,并负责在动作结束后释放资源。多数时候,如果只需要在一个范围内使用文件句柄,应该优先选择 withFile,而不是手动搭配 openFile 和 hClose。
注意
withFile只保证句柄本身被关闭。它不会把业务错误变成成功结果,也不会替你决定失败时如何恢复。它解决的是资源生命周期问题。
Handle 也可以用于写入:
代码清单 14.3 写入文件
module Main (main) where
import System.IO
main :: IO ()
main =
withFile "report.txt" WriteMode $ \handle -> do
hPutStrLn handle "Build report"
hPutStrLn handle "============"
hPutStrLn handle "Status: OK"
这段代码会创建或覆盖 report.txt。如果想保留旧内容并追加新内容,可以把 WriteMode 换成 AppendMode。
14.1.2 缓冲与定位(Buffering and Seeking)
文件读写通常不会每次都直接访问磁盘。运行时和操作系统会使用缓冲区,把多个小操作合并成更大的操作,以提高性能。这就是缓冲。
Haskell 用 BufferMode 描述缓冲策略:
data BufferMode
= NoBuffering
| LineBuffering
| BlockBuffering (Maybe Int)
NoBuffering 表示尽量不缓冲;LineBuffering 常用于终端,每遇到换行就刷新;BlockBuffering 会按块缓冲,块大小可以交给运行时决定,也可以通过 Just size 指定。
代码清单 14.4 设置缓冲模式
module Main (main) where
import System.IO
main :: IO ()
main =
withFile "large.log" ReadMode $ \handle -> do
hSetBuffering handle (BlockBuffering (Just 4096)) -- #1
firstLine <- hGetLine handle
putStrLn firstLine
- #1 将读取缓冲区大小设置为 4096 字节
缓冲最常见的影响出现在写入时。hPutStr 可能只把数据写入缓冲区,而不是立刻写入文件。可以用 hFlush 强制刷新:
hFlush :: Handle -> IO ()
终端程序经常需要这样做。比如在没有换行的情况下打印提示符:
代码清单 14.5 输出提示符并刷新
askName :: IO String
askName = do
putStr "Name: "
hFlush stdout
getLine
stdout、stdin 和 stderr 都是预定义句柄。它们分别代表标准输出、标准输入和标准错误。
文件句柄还支持定位。hTell 可以返回当前文件位置,hSeek 可以移动位置。
代码清单 14.6 在文件中移动读取位置
module Main (main) where
import System.IO
main :: IO ()
main =
withFile "message.txt" ReadMode $ \handle -> do
start <- hTell handle -- #1
first <- hGetChar handle
second <- hGetChar handle
hSeek handle AbsoluteSeek start -- #2
again <- hGetChar handle
print (first, second, again)
- #1 获取当前偏移量
- #2 回到最初位置
hSeek 的第二个参数是 SeekMode:
data SeekMode
= AbsoluteSeek
| RelativeSeek
| SeekFromEnd
AbsoluteSeek 从文件开头计算偏移,RelativeSeek 从当前位置计算偏移,SeekFromEnd 从文件末尾计算偏移。
定位对于二进制格式尤其有用。许多格式在头部记录元数据,后续字节才是真正内容。读取头部后,如果想回到某个位置重新读取,或跳过某段数据,hSeek 就会派上用场。
14.2 从文件读取字节(Reading bytes from a file)
String 是字符列表,它适合表达文本,却不适合表达原始字节。一个图像、压缩包或数据库文件并不是字符序列,而是字节序列。如果用 String 处理这些数据,不仅效率差,还可能因为编码转换得到错误结果。
Haskell 常用 ByteString 表示字节数据。它有严格版本和惰性版本:
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
严格 ByteString 通常表示一段已经在内存中的连续字节。惰性 ByteString 则由多个块组成,适合流式处理大文件。
代码清单 14.7 读取严格 ByteString
module Main (main) where
import qualified Data.ByteString as BS
main :: IO ()
main = do
bytes <- BS.readFile "image.pnm" -- #1
print (BS.length bytes) -- #2
- #1 读取文件内容为严格
ByteString - #2 输出字节数量
ByteString 的内容是 Word8 值。也就是说,每个元素都是一个从 0 到 255 的字节。可以用 BS.take、BS.drop 和 BS.splitAt 操作字节块:
header = BS.take 8 bytes
body = BS.drop 8 bytes
这比把数据转换为列表高效得多。对于真实项目,应该尽量留在 ByteString 世界里处理二进制数据,而不是频繁在列表和 ByteString 之间转换。
如果想从已经打开的句柄读取固定数量的字节,可以使用 BS.hGet。
代码清单 14.8 从句柄读取固定数量字节
module Main (main) where
import qualified Data.ByteString as BS
import System.IO
main :: IO ()
main =
withFile "image.pnm" ReadMode $ \handle -> do
magic <- BS.hGet handle 2 -- #1
space <- BS.hGet handle 1
print magic
print space
- #1 从当前位置读取 2 个字节
默认情况下,Windows 可能会区分文本模式和二进制模式。处理二进制数据时,应该显式使用二进制文件接口:
openBinaryFile :: FilePath -> IOMode -> IO Handle
withBinaryFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r
这能避免换行转换等文本模式行为干扰原始字节。
代码清单 14.9 安全读取二进制文件头
module Main (main) where
import qualified Data.ByteString as BS
import System.IO
readHeader :: FilePath -> IO BS.ByteString
readHeader path =
withBinaryFile path ReadMode $ \handle ->
BS.hGet handle 16
这段函数只读取前 16 个字节。相比 BS.readFile,它不会把整个文件都读入内存。
14.2.1 资源获取与 bracket(Resource acquisition and bracket)
现在回到前面提到的问题:如果手动打开文件,在读取过程中发生异常,如何保证文件一定被关闭?
可以用 bracket。它位于 Control.Exception 模块中:
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
它接收三个动作:
- 获取资源
- 释放资源
- 使用资源
无论使用资源的动作正常结束还是因为异常退出,释放资源的动作都会被调用。
代码清单 14.10 用 bracket 实现安全文件读取
module Main (main) where
import Control.Exception (bracket)
import qualified Data.ByteString as BS
import System.IO
readBytes :: FilePath -> Int -> IO BS.ByteString
readBytes path amount =
bracket
(openBinaryFile path ReadMode) -- #1
hClose -- #2
(\handle -> BS.hGet handle amount) -- #3
main :: IO ()
main = do
bytes <- readBytes "image.pnm" 32
print (BS.length bytes)
- #1 获取资源:打开文件
- #2 释放资源:关闭句柄
- #3 使用资源:读取指定字节数
withFile 和 withBinaryFile 本质上就是这种模式的专门版本。bracket 的价值在于它不限于文件。数据库连接、网络套接字、临时目录、锁文件,都可以用同样模式管理。
还有一个常用变体叫 bracket_:
bracket_ :: IO a -> IO b -> IO c -> IO c
当释放动作不需要获取动作产生的值时,可以使用它。例如先打印日志,再运行一个动作,最后打印结束日志:
withLog :: IO a -> IO a
withLog action =
bracket_
(putStrLn "starting")
(putStrLn "finished")
action
资源管理的核心原则很简单:谁获取资源,谁就负责释放资源。bracket 把这个原则编码成一个普通函数,让资源生命周期不再散落在程序各处。
14.3 文件系统与异常(Working with the filesystem and exceptions)
读取单个文件只是开始。真实程序经常需要检查路径是否存在、创建目录、遍历目录树、复制文件、删除临时文件,或者根据扩展名筛选输入。Haskell 的标准库提供了两个重要模块:
System.Directory用于执行文件系统动作System.FilePath用于纯粹地处理路径字符串
把这两个模块区分开很重要。System.FilePath 不访问磁盘,它只处理路径文本。System.Directory 会与操作系统交互,因此它的函数大多返回 IO。
14.3.1 System.Directory 与 System.FilePath
先看路径组合。不同操作系统使用不同路径分隔符。不要手写 / 或 \,而应该使用 System.FilePath 提供的操作符。
代码清单 14.11 组合路径
module Main (main) where
import System.FilePath
main :: IO ()
main = do
let dir = "data" </> "images" -- #1
file = dir </> "sample" <.> "pnm" -- #2
putStrLn file
- #1 使用适合当前平台的分隔符组合路径
- #2 添加扩展名
常用路径函数还包括:
takeDirectory :: FilePath -> FilePath
takeFileName :: FilePath -> FilePath
takeExtension :: FilePath -> String
replaceExtension :: FilePath -> String -> FilePath
normalise :: FilePath -> FilePath
这些函数都是纯函数。它们不会检查路径是否真的存在。
如果要访问文件系统,需要 System.Directory。
代码清单 14.12 检查路径并创建目录
module Main (main) where
import System.Directory
import System.FilePath
ensureOutputDirectory :: IO FilePath
ensureOutputDirectory = do
let dir = "build" </> "output"
createDirectoryIfMissing True dir -- #1
pure dir
main :: IO ()
main = do
dir <- ensureOutputDirectory
exists <- doesDirectoryExist dir -- #2
print exists
- #1 创建目录;
True表示同时创建缺失的父目录 - #2 检查目录是否存在
常用文件系统动作包括:
doesFileExist :: FilePath -> IO Bool
doesDirectoryExist :: FilePath -> IO Bool
listDirectory :: FilePath -> IO [FilePath]
copyFile :: FilePath -> FilePath -> IO ()
removeFile :: FilePath -> IO ()
renameFile :: FilePath -> FilePath -> IO ()
getFileSize :: FilePath -> IO Integer
getModificationTime :: FilePath -> IO UTCTime
这些动作都可能失败。路径可能不存在,权限可能不足,目标文件可能正在被其他程序使用。因此,检查存在性只能改善错误信息,不能彻底避免错误。
下面这个函数看起来合理,但仍然可能失败:
copyIfExists :: FilePath -> FilePath -> IO ()
copyIfExists source target = do
exists <- doesFileExist source
if exists
then copyFile source target
else putStrLn "source file is missing"
为什么?因为在 doesFileExist source 返回 True 之后、copyFile source target 执行之前,文件仍然可能被删除。这类问题称为检查与使用之间的竞争。文件系统是外部世界,不能假设它在两次 IO 动作之间保持不变。
所以,存在性检查适合用来决定正常分支,但真正执行文件操作时仍然要准备处理异常。
14.3.2 列出文件与目录(Listing files and directories)
listDirectory 会返回目录中的直接子项名称,不包含 . 和 ..。它返回的是相对名称,因此通常要把父目录拼回去。
代码清单 14.13 列出目录中的完整路径
module Main (main) where
import System.Directory
import System.FilePath
listFullPaths :: FilePath -> IO [FilePath]
listFullPaths dir = do
names <- listDirectory dir
pure (map (dir </>) names)
main :: IO ()
main = do
paths <- listFullPaths "data"
mapM_ putStrLn paths
递归遍历目录时,需要区分文件和目录。下面的函数收集目录树中的所有文件。
代码清单 14.14 递归列出文件
module Main (main) where
import System.Directory
import System.FilePath
listFilesRecursive :: FilePath -> IO [FilePath]
listFilesRecursive root = do
entries <- listDirectory root
fmap concat $
mapM
( \name -> do
let path = root </> name
isDir <- doesDirectoryExist path
if isDir
then listFilesRecursive path -- #1
else pure [path] -- #2
)
entries
main :: IO ()
main = do
files <- listFilesRecursive "data"
mapM_ putStrLn files
- #1 子目录继续递归遍历
- #2 文件作为结果返回
这段代码演示了基本思路,但还不够健壮。它没有处理符号链接循环,没有处理权限错误,也没有限制遍历深度。真实工具需要根据目标场景添加这些边界。
如果只想筛选某类文件,可以组合 takeExtension:
pnmFiles :: FilePath -> IO [FilePath]
pnmFiles root = do
files <- listFilesRecursive root
pure (filter ((== ".pnm") . takeExtension) files)
由于 takeExtension 是纯函数,所以可以在 IO 结果取出后用普通列表函数处理。
14.3.3 异常基础(The basics of exceptions)
到目前为止,我们已经多次说文件操作可能失败。在 Haskell 中,这类失败通常通过异常表示。
异常与 Either 的区别在于:Either 是普通值,类型会明确写在函数签名中;异常则会打断当前控制流,直到某处捕获它。
例如:
readFile "missing.txt"
如果文件不存在,它不会返回 Left,而是抛出一个 IOException。如果没有捕获,程序会终止并打印错误信息。
可以用 try 把异常转换成 Either:
代码清单 14.15 使用 try 捕获 IOException
module Main (main) where
import Control.Exception
import qualified Data.ByteString as BS
import System.IO.Error (IOException)
readFileSafe :: FilePath -> IO (Either IOException BS.ByteString)
readFileSafe path =
try (BS.readFile path) -- #1
main :: IO ()
main = do
result <- readFileSafe "missing.bin"
case result of
Left err -> putStrLn ("Could not read file: " <> show err)
Right bytes -> print (BS.length bytes)
- #1 捕获
BS.readFile path抛出的IOException
try 的类型大致如下:
try :: Exception e => IO a -> IO (Either e a)
类型变量 e 表示要捕获的异常类型。这个类型通常需要通过签名告诉编译器,否则它不知道你想捕获哪种异常。
也可以直接使用 catch:
代码清单 14.16 使用 catch 提供失败分支
module Main (main) where
import Control.Exception
import qualified Data.ByteString as BS
import System.IO.Error (IOException)
readOrEmpty :: FilePath -> IO BS.ByteString
readOrEmpty path =
BS.readFile path `catch` handleError
where
handleError :: IOException -> IO BS.ByteString
handleError _ = pure BS.empty
这个函数在读取失败时返回空字节串。它很方便,但也有危险:调用者无法知道文件是真的为空,还是读取失败被吞掉了。通常更好的做法是返回 Either,或者记录错误后让调用者决定如何处理。
异常类型形成一个层次。IOException 表示输入输出相关错误,而 SomeException 可以捕获几乎所有异常。除非正在写最外层日志或错误报告,否则不要轻易捕获 SomeException。捕获太宽会掩盖程序错误,让调试更困难。
14.4 抛出与捕获异常(Throwing and catching exceptions)
除了标准库抛出的异常,我们也可以自己抛出异常。最直接的函数是 throwIO:
throwIO :: Exception e => e -> IO a
注意它返回 IO a,这意味着它可以出现在任何需要 IO 结果的位置,因为它不会正常返回。
自定义异常需要一个数据类型,并为它提供 Exception 实例。
代码清单 14.17 定义自定义异常
{-# LANGUAGE DeriveDataTypeable #-}
module Main (main) where
import Control.Exception
import Data.Typeable
data AppError
= MissingConfig FilePath
| InvalidConfig String
deriving (Show, Typeable)
instance Exception AppError
loadConfig :: FilePath -> IO String
loadConfig path =
throwIO (MissingConfig path)
handleAppError :: AppError -> IO ()
handleAppError err =
putStrLn ("Application error: " <> show err)
main :: IO ()
main =
loadConfig "app.conf" `catch` handleAppError
在较新的 GHC 中,也可以用更多派生扩展减少样板代码。这里显式写出实例,是为了让异常机制更清楚。
并不是所有错误都应该用异常表示。一个有用经验是:
- 业务上预期会发生的失败,优先用
Maybe或Either - 外部世界导致的不可预测失败,可以用异常
- 程序员错误不应该被普通业务逻辑吞掉
例如,解析用户输入失败是预期情况,用 Either String a 很自然。磁盘读取到一半失败则是外部环境问题,用 IOException 很自然。
14.4.1 错误处理(Handling an error)
让我们把本章的内容放在一起,写一个更实际的函数:递归读取某个目录下的文件,并把读取失败的路径与错误保留下来。
先定义结果类型:
代码清单 14.18 表示文件读取结果
module FileReport where
import qualified Data.ByteString as BS
import System.IO.Error (IOException)
data FileReadResult
= FileReadOk FilePath BS.ByteString
| FileReadFailed FilePath IOException
然后实现安全读取:
代码清单 14.19 保留读取错误
module FileReport where
import Control.Exception
import qualified Data.ByteString as BS
import System.IO.Error (IOException)
readOne :: FilePath -> IO FileReadResult
readOne path = do
result <- try (BS.readFile path)
case result of
Left err -> pure (FileReadFailed path err)
Right bytes -> pure (FileReadOk path bytes)
这比失败时返回空内容更诚实。调用者可以看到哪些文件读取成功,哪些失败。
接着组合目录遍历:
代码清单 14.20 读取目录树中的所有文件
module FileReport where
import Control.Exception
import qualified Data.ByteString as BS
import System.Directory
import System.FilePath
import System.IO.Error (IOException)
data FileReadResult
= FileReadOk FilePath BS.ByteString
| FileReadFailed FilePath IOException
listFilesRecursive :: FilePath -> IO [FilePath]
listFilesRecursive root = do
entriesResult <- try (listDirectory root) :: IO (Either IOException [FilePath])
case entriesResult of
Left _ -> pure [] -- #1
Right entries ->
fmap concat $
mapM
( \name -> do
let path = root </> name
isDir <- doesDirectoryExist path
if isDir
then listFilesRecursive path
else pure [path]
)
entries
readTree :: FilePath -> IO [FileReadResult]
readTree root = do
files <- listFilesRecursive root
mapM readOne files
- #1 如果某个目录无法列出,就跳过它
这个实现仍然是一个设计选择。遇到目录无法读取时,我们选择跳过,而不是终止整个程序。对备份工具来说,可能应该报告这个目录错误;对搜索工具来说,跳过无权限目录也许可以接受。错误处理没有唯一正确答案,只有与应用场景匹配的策略。
为了让错误报告更有用,可以把结果格式化出来:
代码清单 14.21 打印读取报告
module Main (main) where
import FileReport
import qualified Data.ByteString as BS
import System.Environment
printResult :: FileReadResult -> IO ()
printResult result =
case result of
FileReadOk path bytes ->
putStrLn (path <> ": " <> show (BS.length bytes) <> " bytes")
FileReadFailed path err ->
putStrLn (path <> ": failed: " <> show err)
main :: IO ()
main = do
[root] <- getArgs
results <- readTree root
mapM_ printResult results
现在程序不会因为一个文件读取失败就直接崩溃。它会尽可能处理剩余文件,并把失败信息保留下来。这种模式在批处理工具中非常常见。
不过,还有一个细节值得注意。代码清单 14.20 的 doesDirectoryExist 本身也可能因为权限问题抛出异常。如果要让遍历器更加健壮,应该把这些检查也包进 try。真实应用中的文件系统代码往往会逐步演化:先写清楚主路径,再根据实际失败场景补上合适的异常边界。
本章最重要的结论是:异常不是神秘机制。它只是 IO 世界中表达外部失败的一种方式。我们可以让异常传播,也可以在边界处捕获它,还可以把它转换为 Either 交给纯粹的数据处理逻辑。
总结
Handle表示程序与文件等外部资源之间的连接。使用完句柄后必须关闭它。withFile和withBinaryFile适合在固定范围内安全使用文件句柄。- 缓冲会影响读写何时真正发生;需要立刻写出内容时可以使用
hFlush。 hTell和hSeek可以读取或改变文件位置,适合处理需要随机访问的格式。- 原始字节数据应该用
ByteString表示,而不是用String。 bracket是通用资源管理模式:获取资源、使用资源、释放资源。System.FilePath提供纯路径处理函数;System.Directory提供实际访问文件系统的IO动作。- 文件系统状态会变化,所以存在性检查不能替代异常处理。
try可以把异常转换为Either,catch可以为异常提供处理分支。- 业务错误通常适合用
Either表示,外部环境错误通常适合在IO中通过异常处理。