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,以及保留的扩展测试消息,先后次序可能不同。
- 临时把
previous + Core.minutes session改成Core.minutes session,测试应失败,OCaml 的 60 会变成 35;开发构建也可能先因previous未使用而拒绝构建。 - 恢复累计逻辑,测试应通过。
- 交换输入行顺序,分组结果不变。原始记录顺序与分组显示顺序是两个不同契约。
独立练习:验证 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,更新入口、样本和测试配置。