15 · 文件输入:在边界关闭资源并处理异常
目标:读取一个文本文件,将可预期的文件错误转换成 result,并确保输入通道被关闭。
核心解析器不需要知道输入来自磁盘。把文件读取放在单独的 Store 模块,就可以分别测试“文件能否打开”和“文本是否符合格式”。这种边界划分让错误定位更直接:cannot read 指输入通道,line 3 指文件内容。
文件是需要关闭的资源。In_channel.with_open_text path f 打开通道,把它交给函数 f,在正常返回或抛出异常后都负责关闭。In_channel.input_all 把内容读成一个字符串;本项目因此针对可放入内存的小文件,不是流式大数据处理器。
某些标准库 I/O 操作以异常报告失败。我们在边界捕获 Sys_error 并转成 Error,让上层继续使用统一的显式结果。不要用 with _ -> ... 吞掉所有异常,那会把程序 bug 也伪装成用户文件错误。
实验:把磁盘数据接入已有核心
在 studylog/ 创建 lib/store.ml:
let read_text path =
try Ok (In_channel.with_open_text path In_channel.input_all)
with Sys_error message -> Error ("cannot read: " ^ message)
let load path =
match read_text path with
| Error message -> Error message
| Ok text -> Core.parse_text text
库内部可以直接使用同级模块 Core;外部入口通过 Studylog.Store 访问。这里没有新增包依赖。
创建 lib/store.mli:
val load : string -> (Core.session list, string) result
读取文本的辅助函数留在内部。接着将 bin/main.ml 完整替换为:
let () =
match Studylog.Store.load "sample.log" with
| Ok sessions -> print_endline (Studylog.Core.summary sessions)
| Error message -> prerr_endline message
在项目根目录创建 sample.log,内容为:
OCaml|25
Types|35
先预测从哪个目录寻找 sample.log,再在 studylog/ 运行:
dune exec bin/main.exe
dune runtest
主程序预期输出:
sessions=2 minutes=60
相对路径相对于程序的工作目录,不是相对于 main.ml 所在位置。本课命令从项目根目录执行,所以输入也放在项目根目录。原有核心测试继续通过,文件边界没有改变解析规则。
分别制造内容错误与文件错误
- 把输入第二行改成
Types|many,再运行,预期是line 2: minutes must be an integer。 - 恢复输入。暂时将入口中的路径改成不存在的
missing-studylog-input.log,再次运行。消息以cannot read:开头,后面的路径与系统原因随操作系统变化。 - 恢复入口路径。此时入口只是显示错误,第 16 课才统一设置非零退出状态。
独立练习:给 Store.load 增加临时文件测试,分别检查正常内容和坏内容。文件清理必须在测试失败时也执行。使用 Filename.temp_file 生成唯一文件名;Fun.protect ~finally f 无论 f 成功还是抛异常都会调用清理函数,~finally 是标签参数,明确该回调的作用。
完整参考测试
将 test/dune 完整替换为以下配置,显式指定各自模块,避免两个测试争用同一目录的源码:
(test
(name core_test)
(modules core_test)
(libraries studylog))
(test
(name store_test)
(modules store_test)
(libraries studylog))
创建 test/store_test.ml:
let with_file contents f =
let path = Filename.temp_file "studylog-" ".log" in
Fun.protect
~finally:(fun () -> Sys.remove path)
(fun () ->
Out_channel.with_open_text path (fun channel ->
output_string channel contents);
f path)
let () =
with_file "OCaml|25\n" (fun path ->
match Studylog.Store.load path with
| Ok sessions -> assert (Studylog.Core.total sessions = 25)
| Error message -> failwith message);
with_file "OCaml|bad\n" (fun path ->
assert (Studylog.Store.load path =
Error "line 1: minutes must be an integer"));
print_endline "store tests passed"
Out_channel.with_open_text 是输出通道的对应资源管理函数;output_string 写入内容。先完成写入并关闭,再调用读取。运行 dune runtest --force,应同时看到核心与文件测试的成功消息,顺序由 Dune 调度决定。
保留这些测试;最终项目验收包含它们。若你没有独立完成,也请复制参考实现运行后再继续。
若文件存在但程序报找不到,执行 pwd 检查工作目录。若 In_channel 不存在,检查是否运行在旧编译器上,回到第 1 课激活正确环境。若临时文件测试偶尔读到空内容,检查写入通道是否在读取前已经关闭。
本课结束新增 lib/store.ml、lib/store.mli、sample.log,替换入口,并保留双测试配置与 test/store_test.ml。