Course Builder个人学习工作台 · 本地模式
OCaml:从类型推导到可测试的命令行工具

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 所在位置。本课命令从项目根目录执行,所以输入也放在项目根目录。原有核心测试继续通过,文件边界没有改变解析规则。

分别制造内容错误与文件错误

  1. 把输入第二行改成 Types|many,再运行,预期是 line 2: minutes must be an integer。
  2. 恢复输入。暂时将入口中的路径改成不存在的 missing-studylog-input.log,再次运行。消息以 cannot read: 开头,后面的路径与系统原因随操作系统变化。
  3. 恢复入口路径。此时入口只是显示错误,第 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。

参考资料