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 课展示如何让接口强制调用者经过验证。不要把“编译成功”理解成业务数据必然有效。
- 把
extend 10改成extend 0,两行应一致。 - 改成
extend (-30),新记录会变成负数。解释为什么编译器仍接受它。 - 恢复主示例,不把这次负数实验带入后续项目。
独立练习:实现 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 }。