12 · 模块与 Dune:组装 StudyLog 核心
目标:将已经学过的解析和统计函数放进库,并通过 Dune 从可执行程序调用它。
到这里,真正的业务计算已经完成:一行解析、整段解析和列表统计。现在把它们从独立实验迁入项目。不要同时重写算法;保持行为不变,才能把注意力放在模块之间的连接上。
一个 .ml 文件构成一个模块,例如 core.ml 提供 Core。Dune 的 library 默认会包一层命名空间;库名 studylog 对应 Studylog,因此外部通过 Studylog.Core 访问内部模块。(libraries studylog) 声明依赖,让 Dune 知道如何编译和链接。
建立最小结构
从 ocaml-study 练习根目录开始:
mkdir -p studylog/lib studylog/bin
cd studylog
创建 dune-project:
(lang dune 3.0)
(name studylog)
创建 lib/dune:
(library
(name studylog))
创建 bin/dune:
(executable
(name main)
(libraries studylog))
这些是 Dune 的声明式配置,不是 OCaml 代码。lang 指配置语法版本,不是你必须安装的工具版本;本课程的 Dune 3.20.2 可以处理 3.0 语法。
完整的计算核心
创建 lib/core.ml。代码来自第 7–9 课,summary 只是把数量和总分钟数合成报表字符串。
type session = { topic : string; minutes : int }
let parse_line line =
match String.split_on_char '|' line with
| [topic; raw_minutes] ->
let topic = String.trim topic in
let raw_minutes = String.trim raw_minutes in
if topic = "" then Error "empty topic"
else
(match int_of_string_opt raw_minutes with
| None -> Error "minutes must be an integer"
| Some minutes when minutes < 1 || minutes > 1440 ->
Error "minutes must be between 1 and 1440"
| Some minutes -> Ok { topic; minutes })
| _ -> Error "expected topic|minutes"
let parse_text text =
let rec loop number acc remaining =
match remaining with
| [] -> Ok (List.rev acc)
| line :: rest ->
if String.trim line = "" then loop (number + 1) acc rest
else
(match parse_line line with
| Ok session -> loop (number + 1) (session :: acc) rest
| Error message ->
Error (Printf.sprintf "line %d: %s" number message))
in
loop 1 [] (String.split_on_char '\n' text)
let total sessions =
List.fold_left (fun acc s -> acc + s.minutes) 0 sessions
let summary sessions =
Printf.sprintf "sessions=%d minutes=%d"
(List.length sessions) (total sessions)
创建 bin/main.ml:
let () =
match Studylog.Core.parse_text "OCaml|25\nTypes|35\n" with
| Ok sessions -> print_endline (Studylog.Core.summary sessions)
| Error message -> prerr_endline message
prerr_endline 向标准错误打印一行;标准输出用于结果。先预测库文件本身是否会打印内容,再在 studylog/ 执行:
dune build
dune exec bin/main.exe
预期输出:
sessions=2 minutes=60
库只有类型和函数定义;打印只发生在入口文件。Dune 把产物放在 _build/,不要在这里修改源码。可执行文件名来自 (name main),所以入口源码必须是 main.ml。
看见重建,再增加一个小能力
- 把入口的 35 改为 45,重新
dune exec bin/main.exe。它会先重建,输出 70 分钟。 - 把入口改成坏输入,结果应显示解析错误,而不是成功报表。
- 恢复完整主示例。错误退出状态会在第 16 课统一处理。
独立练习:在 Core 增加 count_topic topic sessions,返回某主题的记录数;让入口在成功分支中同时打印 OCaml 的记录数。本课还未加入 .mli,新增函数会自动可见。
参考修改
追加到 lib/core.ml:
let count_topic topic sessions =
List.length (List.filter (fun s -> s.topic = topic) sessions)
把入口成功分支替换成以下片段:
| Ok sessions ->
print_endline (Studylog.Core.summary sessions);
Printf.printf "ocaml sessions=%d\n"
(Studylog.Core.count_topic "OCaml" sessions)
运行同一命令,第二行应为 ocaml sessions=1。实验后恢复主示例,下一课组不会依赖这项练习扩展。
如果找不到 Studylog,先检查 bin/dune 是否声明库依赖,再检查当前目录。若找不到 Core,检查文件名与大小写;模块名首字母大写。如果错误显示库模块被多个 stanza 使用,确认库与可执行源码分别在 lib/、bin/,不要全部堆在同一目录。
本课结束必须存在 dune-project、lib/dune、lib/core.ml、bin/dune、bin/main.ml。第 14 课开始在这个项目上继续增加文件。