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

17 · Map 与 Functor:按主题汇总记录

目标:用 Map 累计每个主题的时长,并把结果接入已有命令行程序。

总时长回答“学了多久”,按主题汇总回答“时间花在哪里”。你可以先得到所有主题再逐个筛选,但那会重复扫描记录。一个从主题到累计分钟数的映射,可以在遍历时不断更新对应主题。

Map.Make(String) 是一次 functor 应用:它接收模块,返回模块。普通函数把值映射到值;functor 在模块层组合类型和操作。Map.Make 要求参数模块提供键类型 t 与比较函数 compare。标准库 String 已经提供它们,所以得到的 Map 以字符串为键。

Map 是不可变结构。add key value old_map 返回新 Map,不修改旧 Map;内部可以共享没有变化的结构。比较函数决定键的相等与排序。本课使用 String.compare,主题按大小写敏感的字符串字节顺序排列,不是中文拼音或自然语言排序。

带着 Go map、Python dict 或 JS Map 的使用经验看这段代码,最需要检查的是更新结果去了哪里:调用 Topics.add 之后,后续步骤必须使用它返回的新映射。这里让 fold_left 把新映射交给下一轮,旧映射仍然有效。另一个区别是 Map.Make 接收整个模块作为参数;本课先学会使用这个标准库 functor,不要求你立即设计自己的通用模块框架。

实验:从旧累计值推导新累计值

在 studylog/ 创建 lib/report.ml:

module Topics = Map.Make (String)

let groups sessions =
  let add_session totals session =
    let topic = Core.topic session in
    let previous =
      match Topics.find_opt topic totals with
      | None -> 0
      | Some minutes -> minutes
    in
    Topics.add topic (previous + Core.minutes session) totals
  in
  List.fold_left add_session Topics.empty sessions |> Topics.bindings

let render sessions =
  groups sessions
  |> List.map (fun (topic, minutes) -> Printf.sprintf "%s: %d" topic minutes)
  |> String.concat "\n"

empty 是空映射;find_opt 查询旧值,尚未出现时得到 None;add 保存新值;bindings 把映射转换成按键递增排列的二元组列表。groups 的公开结果只含列表和元组,调用者不必知道内部用了 Map。

创建 lib/report.mli:

val groups : Core.session list -> (string * int) list
val render : Core.session list -> string

将 bin/main.ml 完整替换为:

let usage = "Usage: studylog [--by-topic] FILE"

let run render path =
  match Studylog.Store.load path with
  | Error message ->
      prerr_endline message;
      1
  | Ok sessions ->
      let output = render sessions in
      if output <> "" then print_endline output;
      0

let () =
  let status =
    match Array.to_list Sys.argv with
    | [_; "--help"] ->
        print_endline usage;
        0
    | [_; "--by-topic"] ->
        prerr_endline usage;
        2
    | [_; "--by-topic"; path] -> run Studylog.Report.render path
    | [_; path] -> run Studylog.Core.summary path
    | _ ->
        prerr_endline usage;
        2
  in
  exit status

新增的只是报表函数选择:run 将显示函数作为参数传入。两条路径共用文件读取和错误处理。单独写 --by-topic 是用法错误,不能把它误当成路径。其他单个参数仍作为路径,本程序不是完整的选项解析框架。

将 sample.log 完整替换为:

OCaml|25
Types|35
OCaml|35

先预测输出按输入顺序还是按键排序,再运行:

dune exec bin/main.exe -- sample.log
dune exec bin/main.exe -- --by-topic sample.log

预期输出:

sessions=3 minutes=95
OCaml: 60
Types: 35

第一次遍历 OCaml 得到 25,第二次遇到同一键时读取 25 再加 35。Topics.add 的值必须是累计值;直接存当前时长会覆盖旧结果。

给新功能留下回归测试

创建 test/report_test.ml:

let parse text =
  match Studylog.Core.parse_text text with
  | Ok sessions -> sessions
  | Error message -> failwith message

let () =
  let sessions = parse "Types|35\nOCaml|25\nOCaml|35" in
  assert (Studylog.Report.groups sessions = [("OCaml", 60); ("Types", 35)]);
  assert (Studylog.Report.render sessions = "OCaml: 60\nTypes: 35");
  assert (Studylog.Report.groups [] = []);
  assert (Studylog.Report.render [] = "");
  print_endline "report tests passed"

在第 15 课已有双测试配置的 test/dune 末尾追加:

(test
 (name report_test)
 (modules report_test)
 (libraries studylog))
dune runtest --force

应看到 core tests passed、store tests passed、report tests passed,以及保留的扩展测试消息,先后次序可能不同。

  1. 临时把 previous + Core.minutes session 改成 Core.minutes session,测试应失败,OCaml 的 60 会变成 35;开发构建也可能先因 previous 未使用而拒绝构建。
  2. 恢复累计逻辑,测试应通过。
  3. 交换输入行顺序,分组结果不变。原始记录顺序与分组显示顺序是两个不同契约。

独立练习:验证 OCaml 和 ocaml 被当作不同主题,以及“分组后的分钟总和等于原始总和”。无需修改实现。

参考验收

追加到 test/report_test.ml:

let () =
  let sessions = parse "OCaml|25\nocaml|10\nOCaml|35" in
  let groups = Studylog.Report.groups sessions in
  assert (groups = [("OCaml", 60); ("ocaml", 10)]);
  let grouped_total = List.fold_left (fun sum (_, n) -> sum + n) 0 groups in
  assert (grouped_total = Studylog.Core.total sessions);
  print_endline "grouping checks passed"

这条总量不变量能帮助发现记录丢失,不过单靠它发现不了两个主题被错误合并,因此两个断言都有意义。

若新增 Report 后外部找不到模块,检查源码是否在 lib/,以及接口与实现同名。如果类型提示期望 Topics.t 却得到列表,检查是否过早调用了 bindings。本课结束新增 lib/report.ml、lib/report.mli、test/report_test.ml,更新入口、样本和测试配置。

参考资料