05 · 变体与匹配:把不同情况写进类型
目标:用 variant 表示一次计时的不同状态,并写出覆盖所有情况的匹配。
假设你用三个字段表示计时器:is_running、is_finished、minutes。那么“既运行又结束”也能被构造出来,调用者还得猜分钟数有没有意义。variant 直接列出允许的情况:尚未开始、正在运行、已经结束;只有结束状态需要携带最终时长。
构造器以大写字母开头。Finished 25 是一个完整值,25 是这个情况携带的数据。它不是一个可以随意访问的公共字段,只有匹配到 Finished 才能取出。模式匹配因此同时完成分支选择和数据拆解。
如果你习惯在 Go struct 或 JS 对象里放一个 kind 字段,再约定某些字段只在某个 kind 下有效,这里的变化是:构造器直接规定对应数据。例如 Running 携带主题,Finished 携带时长。随后 match 不只检查标签,还把该分支的数据绑定出来。重点观察本课删除一个分支后的编译诊断:类型里列出的情况可以成为遗漏检查的依据。
实验:三个状态,各有自己的含义
创建 variants.ml:
type timer = Idle | Running of string | Finished of int
let describe state =
match state with
| Idle -> "idle"
| Running topic -> "running: " ^ topic
| Finished minutes when minutes >= 60 -> "long session"
| Finished minutes -> Printf.sprintf "finished: %d" minutes
let () =
print_endline (describe Idle);
print_endline (describe (Running "OCaml"));
print_endline (describe (Finished 25));
print_endline (describe (Finished 60))
^ 拼接字符串。when 是守卫:先匹配构造器,再检查条件。匹配按从上到下的顺序选中第一个合适分支,后面的分支不会再运行。先预测 60 分钟会进入哪个分支,再执行:
ocaml variants.ml
预期输出:
idle
running: OCaml
finished: 25
long session
注意两个 Finished 分支都必要:仅有 minutes >= 60 的守卫不能覆盖所有整数。所有分支仍然要返回同一个类型,这里是 string。
让编译器帮助你找到遗漏
- 临时删掉最后一个
Finished minutes分支,再运行。编译器会提示匹配不穷尽;执行到Finished 25时还会产生Match_failure。 - 恢复分支,交换两个
Finished分支的顺序。无守卫的分支会先接住所有已结束记录,60 也会显示finished: 60。 - 恢复主示例。今后增加新构造器时,优先逐个检查匹配处,不要急于增加
_通配分支来压掉提示。
独立练习:增加 Paused of string,显示为 paused: OCaml。保留其他三个状态的输出。再回答:为什么 Finished (-1) 仍然能被构造?
参考修改
把类型声明改成:
type timer = Idle | Running of string | Paused of string | Finished of int
在 describe 的 Running 分支后添加 | Paused topic -> "paused: " ^ topic。在文件末尾添加以下检查,再执行原命令:
let () = assert (describe (Paused "OCaml") = "paused: OCaml")
这是基于主示例的修改方案,不是三个独立程序。断言应通过,原四行输出保持一致。
variant 限制了允许的状态形状,但 Finished 的载荷仍然是任意 int;负数属于 int。第 8 课的验证和第 13 课的抽象接口会处理这种约束。
若写 running 后出现 Unbound value,检查是否忘了构造器必须大写。若出现匹配遗漏,不要只看当前测试数据;警告针对类型允许的全部值。若看似有匹配却进错分支,检查是否有过早的 _ 或无守卫分支。
这套思路会直接用在 StudyLog 的失败表达上:解析成功携带记录,解析失败携带原因。调用者将被迫说明自己如何处理两种情况。