struct 与 match:代数数据与模式匹配
你在 Kotlin 里写 sealed class + when,在 TypeScript 里写判别联合,在 Rust 里写 enum + match。这三样东西是同一样东西,而且它们都有一个共同的祖宗。这一章讲那个祖宗,也讲一个更重要的设计原则:让非法状态无法被表示出来。顺便,我会诚实地告诉你一个 Racket 输给 Kotlin 的地方 —— 这本书不是来给 Lisp 唱赞歌的。
struct:一个记录
(struct circle (r) #:transparent) (struct rect (w h) #:transparent) (define c (circle 5)) (circle-r c) ; ⇒ 5 访问字段:结构体名-字段名 (circle? c) ; ⇒ #t 类型谓词,自动生成 (rect? c) ; ⇒ #f
一行 struct,白送你三样东西:构造函数(circle)、字段访问器(circle-r)、类型谓词(circle?)。
#:transparent 不是可选的装饰 —— 忘了它你会吃大亏看这个实测对比:
; 不加 #:transparent > (opaque-pt 1 2) #<opaque-pt> ; ← 打印不出内容,调试时你是瞎的 > (equal? (opaque-pt 1 2) (opaque-pt 1 2)) #f ; ← 内容一样,但不相等! ; 加了 #:transparent > (clear-pt 1 2) #(struct:clear-pt 1 2) ; ← 能看见内容 > (equal? (clear-pt 1 2) (clear-pt 1 2)) #t ; ← 按内容比较
默认的 struct 是不透明的:打印不出内容,equal? 按身份(是不是同一个对象)比较,不按内容。
这个默认值经常坑到新手 —— 你的测试 (check-equal? (make-point 1 2) (make-point 1 2)) 会莫名其妙地失败。
这正是 Kotlin 的 data class 替你做掉的事:它自动生成 equals、hashCode、toString。Kotlin 的默认值是对的(data 一加全都有),Racket 的默认值是保守的(你得记得加 #:transparent)。这一局 Kotlin 赢。
规矩:写 struct 就顺手加 #:transparent,除非你明确想要不透明(比如封装内部状态)。
代数数据类型:两种「组合」
「代数数据类型」(ADT)这个名字唬人,其实只有两个概念:
| 积类型(product) | 和类型(sum) | |
|---|---|---|
| 含义 | 「并且」—— 一个圆有半径并且有颜色 | 「或者」—— 一个图形是圆或者是矩形 |
| Racket | (struct circle (r color)) | 几个 struct + match 分别处理 |
| Kotlin | data class(字段) | sealed interface(子类) |
| TypeScript | { a: A; b: B } | A | B(联合类型) |
| 为什么叫「积」「和」 | 可能状态数 = 相乘(3 种半径 × 2 种颜色 = 6 种) | 可能状态数 = 相加(3 种圆 + 2 种矩形 = 5 种) |
就这两个。你所有的数据建模,都是这两种积木的堆叠。
而 Java 长期以来只有积类型(class 有字段),没有像样的和类型 —— 你只能用继承 + instanceof 硬凑,而编译器完全不知道「子类就这么几个」。这就是为什么 Java 17 的 sealed 是一个大事:它终于把和类型补上了。
match:模式匹配
(define (area s)
(match s
[(circle r) (* pi r r)]
[(rect w h) (* w h)]))
(area (rect 3 4)) ; ⇒ 12
看清楚 (circle r) 干了两件事:① 判断 s 是不是一个 circle;② 如果是,把它的字段解构出来绑到 r 上。
没有 match 的话,你得这么写:
; ✗ 没有 match 的世界
(define (area s)
(cond
[(circle? s) (let ([r (circle-r s)]) ; 先判断,再手动取字段
(* pi r r))]
[(rect? s) (let ([w (rect-w s)]
[h (rect-h s)])
(* w h))]))
match 把「判断 + 解构」合并成了一个动作。这就是它全部的价值 —— 而这个价值大到所有现代语言都在补它(Java 21 的 record pattern、Python 3.10 的 match、C# 的 pattern matching)。
match 能匹配的东西(比你想的多)
; 列表结构 (match '(1 2 3) [(list a b c) (list 'three a b c)]) ; ⇒ '(three 1 2 3) ; 「头 + 剩下的」—— 省略号 (match '(1 2 3 4 5) [(list first rest ...) (list first rest)]) ; ⇒ '(1 (2 3 4 5)) ; 字面量(注意这个,它是解释器的基础) (match '(add 1 2) [(list 'add a b) (+ a b)] ; ⇒ 3 [(list 'mul a b) (* a b)]) ; 谓词 (match 42 [(? even? n) (list 'even n)]) ; ⇒ '(even 42) ; 守卫(when) (match 15 [(? number? n) #:when (> n 10) 'big] [_ 'small]) ; ⇒ 'big ; _ 是通配符:匹配任何东西,不绑定
(list 'add a b)它在匹配一段代码。
还记得第 4 章吗 —— '(add 1 2) 既是数据,也可以是代码。而 match 让你能直接对着代码的形状写分支。
这就是第 21 章那个解释器的写法:
(match expr [(? number? n) n] [(? symbol? x) (lookup x env)] [(list 'if c t e) (if (my-eval c env) …)] [(list 'lambda ps b) (make-closure ps b env)] [(list f args ...) (my-apply …)])
一个解释器的骨架,就是一个 match。你现在已经能读懂它了 —— 而且你会发现,它读起来就像是那门语言的语法规范本身。
诚实时间:Racket 在这一局输给了 Kotlin
看这个。我故意漏掉一个分支:
(struct circle (r) #:transparent)
(struct rect (w h) #:transparent)
(struct tri (a b c) #:transparent) ; ← 三种图形
(define (area s)
(match s
[(circle r) 'circle]
[(rect w h) 'rect])) ; ← 漏了 tri!
Racket 编译时一声不吭。直到运行到那一行:
> (area (tri 3 4 5)) match: no matching clause for (tri 3 4 5)
运行时才炸。而 Kotlin:
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape
data class Tri(val a: Double, val b: Double, val c: Double) : Shape
fun area(s: Shape): Double = when (s) {
is Circle -> …
is Rect -> …
} // ✗ 编译错误:'when' expression must be exhaustive,
// add necessary 'is Tri' branch or 'else' branch
Kotlin 在编译期就告诉你漏了分支。Racket 要等到运行时,而且是在那个特定的输入真的出现的时候。
这个差距的实际后果很严重:当你给 sealed interface 新加一个子类型时,Kotlin 编译器会把项目里所有需要更新的 when 全部标红。这是重构时最值钱的能力之一 —— 编译器给了你一份完整的待办清单。
而在 Racket 里,你加一个新的 struct,然后祈祷你记得改了所有的 match。
根本原因:Racket 是动态类型的。它压根不知道「Shape 只有三种」这件事 —— match 只是一串按顺序试的模式,它不知道自己应该覆盖什么。
缓解手段(不能根治):
- 永远写一个兜底分支,并且让它响亮地失败:
[other (error 'area "未知的图形: ~a" other)]。这至少把「静默的错误行为」变成了「明确的崩溃」。 - 用
#lang typed/racket,它有真正的和类型和穷尽性检查。但代价是你离开了这本书讲的那个 Racket。 - 用
struct的继承(#:super)配合契约(第 24 章)—— 部分帮助,但仍是运行时的。
这就是动态类型的价格。它买到了灵活(第 21 章那个一百行的解释器,在 Java 里要几千行),卖掉了这个。这本书不会假装这笔交易没有代价。
真正的重点:让非法状态无法表示
ADT 最大的价值不是「代码更短」,是它能让某些 bug 在物理上不可能存在。
看这个你一定写过的东西:
// ✗ 一个典型的「非法状态可表示」的设计
data class UiState(
val isLoading: Boolean,
val data: List<Item>?,
val error: String?
)
这个类型能表示多少种状态?2 × 2 × 2 = 8 种(每个字段有/无)。
但你的业务里只有 3 种是合法的:加载中、成功、失败。
剩下 5 种是什么?「正在加载并且已经有数据并且还报了个错」—— 这是什么鬼?它没有意义,但你的类型系统允许它存在,于是:
- 你必须在每一处使用它的地方写防御性判断:
if (isLoading) … else if (error != null) … else if (data != null) … - 你必须记住那个隐含的优先级(先判 loading 还是先判 error?)—— 而且团队里每个人记的可能不一样
- 总有一天,某个 bug 会让这三个字段进入一个自相矛盾的组合,然后 UI 显示成一团乱麻
用和类型重写:
// ✓ 非法状态在物理上不存在
sealed interface UiState {
object Loading : UiState
data class Success(val data: List<Item>) : UiState
data class Failure(val error: String) : UiState
}
现在可能状态数 = 3。一个不多,一个不少。
- 「加载中还带着错误」写不出来 —— 不是「不该写」,是编译器不让
Success里的data不可空 —— 因为成功了就一定有数据,这是类型说的when强制你处理所有三种情况
「Make illegal states unrepresentable.」(让非法状态无法被表示出来)
方法:数一数你的类型能表示多少种状态,再数一数业务上合法的有多少种。如果前者大于后者,那个差额就是你未来 bug 的储量。
典型的「差额」信号:
- 一堆互斥的可空字段(
data: T?和error: E?不可能同时有值)→ 该用和类型 - 一个
Boolean字段 + 一个「只在它为 true 时才有意义」的字段 → 该用和类型 - 注释里写着「当 X 为 A 时,Y 必须为 null」→ 这条注释就是类型系统的失败报告
回流
从 Racket 带回去的:
① match 的「字面量模式」,让你能对着数据的形状写分支。TS 的判别联合就是这个:
type Action =
| { type: "add"; payload: Item }
| { type: "remove"; id: string }
| { type: "clear" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "add": return [...state, action.payload]; // ← 这里 TS 知道有 payload
case "remove": return state.filter(x => x.id !== action.id);
case "clear": return [];
}
}
Redux 的整个设计,就是「和类型 + 模式匹配」。而它之所以在 TS 里好用,正是因为判别联合让编译器知道「在 case "add" 里,action 一定有 payload」。
② 数一数你的状态数。这是我从这一章希望你带走的唯一一个可执行动作。下次设计一个 data class,数一数。
从 Kotlin 带回 Racket 的(反向回流):
③ 承认动态类型的代价,并主动补偿。在 Racket 里写 match 时,永远加一个会响亮失败的兜底分支:
(match s [(circle r) …] [(rect w h) …] [other (error 'area "未处理的图形类型: ~a" other)]) ; ↑ 这一行不是可选的。它是你在动态语言里能买到的、最便宜的保险。
好的工程师不迷信自己的工具。Racket 在这件事上比 Kotlin 弱,那就用纪律补上 —— 而不是假装它不存在。
下一章
我们已经能处理任意形状的有限数据了。
下一章处理无限的。是的,无限 —— 一个包含所有自然数的列表,你可以对它 map、filter,然后取出前五个。
而这件事的机制,正是 Kotlin 的 Sequence、JS 的 function*、Java 的 Stream 共同的祖先。