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

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。

看见重建,再增加一个小能力

  1. 把入口的 35 改为 45,重新 dune exec bin/main.exe。它会先重建,输出 70 分钟。
  2. 把入口改成坏输入,结果应显示解析错误,而不是成功报表。
  3. 恢复完整主示例。错误退出状态会在第 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 课开始在这个项目上继续增加文件。

参考资料