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

13 · 抽象接口:让合法状态只能经由构造函数产生

目标:用 .mli 隐藏记录的内部表示,使外部代码只能通过经过验证的接口取得记录。

第 8 课的解析器会拒绝负数,但第 12 课的库默认公开了 record 定义。外部调用者仍能自己写一条负分钟记录,再交给 total。验证逻辑存在,并不意味着它是必经之路。

与 core.ml 配对的 core.mli 指定模块的公开签名。val 声明公开函数的类型,type session 只公开类型名称,不说明其表示。这叫抽象类型。库内部仍然知道它是 record,库外部只能使用接口允许的操作。

这里的“接口”不要直接套用 Go 的 interface:Go 的接口描述满足约束的类型集合,常见用法是通过方法集合表达能力;.mli 描述整个模块公开的类型和值。从工程使用角度,可以先把它想成一份编译器会强制检查的包级 API 契约。本课的关键是让记录表示留在模块内部,只公开创建和观察它的操作。Go 规范:接口类型

抽象不保证实现没有 bug;它让约束的检查集中在少量入口。在安全、正常的 OCaml 代码中,只要这些入口维护约束,调用者就不能绕过它们直接伪造记录。这里不涉及 Obj、不可信反序列化等绕过类型系统的手段。

实验:只公开需要的能力

在第 12 课的 studylog/ 项目中,创建 lib/core.mli:

type session

val topic : session -> string
val minutes : session -> int
val parse_text : string -> (session list, string) result
val total : session list -> int
val summary : session list -> string

session 在签名里没有 = { ... };这是隐藏表示的关键。parse_line 没有出现在签名中,成为内部辅助函数。外部拿到 session 的唯一创建入口是 parse_text,它逐行验证输入。

在 lib/core.ml 末尾追加两个只读访问函数,保留此前全部代码:

let topic session = session.topic
let minutes session = session.minutes

先预测现有入口是否需要改动,再在 studylog/ 执行:

dune build
dune exec bin/main.exe

预期输出仍为:

sessions=2 minutes=60

调用者此前只使用 parse_text 和 summary,没有依赖 record 字段,因此不用改。以后分组统计使用 Core.topic、Core.minutes,这样即使内部表示改变,调用端也有机会保持原样。

一次有意失败的编译

  1. 在 bin/main.ml 末尾临时添加下面这行。它是故意错误的实验片段,不能作为可运行示例:
let invalid : Studylog.Core.session = { topic = "OCaml"; minutes = -1 }
  1. 运行 dune build,应看到 Unbound record field topic 一类诊断,构建失败。错误的核心是字段表示不公开,不是因为编译器理解了负数的业务含义。
  2. 删除这行,再次 dune build,恢复成功。不要把故意失败的代码留给下一课。

.mli 不是文档装饰:编译器会核对实现是否提供了签名声明的内容。如果签名写了函数而 .ml 没有实现,会构建失败;若实现多了内部辅助函数,它们可以不公开。

独立练习:增加 is_long : session -> bool,定义为分钟数至少 30。外部代码应能调用它,但仍不能访问 record 字段。修改哪些文件?用一条 25 分钟和一条 35 分钟记录验收。

参考修改与验收代码

在 lib/core.ml 末尾增加:

let is_long session = session.minutes >= 30

在 lib/core.mli 增加 val is_long : session -> bool。在 bin/main.ml 末尾追加:

let () =
  match Studylog.Core.parse_text "OCaml|25\nTypes|35" with
  | Ok [short; long] ->
      assert (not (Studylog.Core.is_long short));
      assert (Studylog.Core.is_long long);
      print_endline "interface checks passed"
  | _ -> failwith "unexpected parse result"

failwith 抛出带消息的异常,适合让这段实验在不满足预期时立即失败。正常用户输入错误仍然通过 result 返回。

运行原命令,增加一行 interface checks passed。结束后删除这项练习增加的函数、签名和入口检查,保留主实验的 .mli 与两个访问函数。

若调用新函数得到 Unbound value,检查是否只改了 .ml 而漏改 .mli。若内部实现也不能访问字段,检查是否错误地把 .ml 的 record 定义一起删掉了:隐藏表示只发生在 .mli。本课结束新增 lib/core.mli,并在 lib/core.ml 末尾保留 topic 与 minutes。

参考资料