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

04 · 元组与记录:给学习记录一个形状

目标:用不可变 record 表示一条学习记录,并生成修改后的新记录。

只有一个时长时,整数就够了。加上主题之后,可以用元组 ("OCaml", 25) 临时把两项值放在一起;它的类型是 string * int,字段靠位置识别。若记录会长期流经多个函数,具名字段更容易读:topic 和 minutes 不会因为你忘记第几个位置而混淆。

record 是“这些信息同时存在”的数据结构。一个 session 必须同时有主题和分钟数;第 5 课会介绍表示“几种情况之一”的 variant。我们先保持记录不可变:更新时构造新值,旧值仍然存在。这让“原始输入”和“修正后的输入”可以同时保留。

实验:更新记录,不修改原始数据

创建 records.ml:

type session = { topic : string; minutes : int }

let describe s =
  Printf.sprintf "%s: %d min" s.topic s.minutes

let extend extra s = { s with minutes = s.minutes + extra }

let () =
  let pair = ("OCaml", 25) in
  let (topic, minutes) = pair in
  let original = { topic; minutes } in
  let revised = extend 10 original in
  print_endline (describe original);
  print_endline (describe revised);
  assert (original.minutes = 25)

Printf.sprintf 生成字符串而不打印。let (topic, minutes) = pair 用模式拆开元组。{ topic; minutes } 是 { topic = topic; minutes = minutes } 的简写。{ s with ... } 复制其他字段,只替换指定字段。

运行前预测:最后的断言会不会失败?

ocaml records.ml

预期输出:

OCaml: 25 min
OCaml: 35 min

断言通过说明旧值没有被修改。此处记录中的字段都是不可变值;以后若记录包含数组等可变对象,复制外层记录不会自动深拷贝里面的对象。

字段类型不等于业务约束

minutes : int 能拒绝字符串,却不能拒绝 -3。同样,topic : string 不能保证非空。类型声明先保证数据形状;第 8 课加输入验证,第 13 课展示如何让接口强制调用者经过验证。不要把“编译成功”理解成业务数据必然有效。

  1. 把 extend 10 改成 extend 0,两行应一致。
  2. 改成 extend (-30),新记录会变成负数。解释为什么编译器仍接受它。
  3. 恢复主示例,不把这次负数实验带入后续项目。

独立练习:实现 rename new_topic s,返回主题改变、时长不变的新记录。用断言同时检查新旧值,不只肉眼看打印。

参考实现与边界说明

追加到主文件:

let rename new_topic s = { s with topic = new_topic }
let () =
  let before = { topic = "OCaml"; minutes = 25 } in
  let after = rename "Types" before in
  assert (after.topic = "Types");
  assert (after.minutes = 25);
  assert (before.topic = "OCaml");
  print_endline "record checks passed"

新增输出是 record checks passed。extend (-30) 的问题是业务规则,没有违反 int 类型;必须由验证逻辑或更受约束的接口处理。

若出现 Unbound record field,先确认 type session 位于使用之前,并检查拼写。如果声明了多个同名字段的类型而推导选错,可用 (s : session) 明确参数类型。若出现 This record expression is expected to have type ...,检查是否误把元组 (a, b) 当成了记录 { topic = a; minutes = b }。

参考资料