卷 III · 数据CH 11深度 11/25

struct 与 match:代数数据与模式匹配

你在 Kotlin 里写 sealed class + when,在 TypeScript 里写判别联合,在 Rust 里写 enum + match这三样东西是同一样东西,而且它们都有一个共同的祖宗。这一章讲那个祖宗,也讲一个更重要的设计原则:让非法状态无法被表示出来。顺便,我会诚实地告诉你一个 Racket 输给 Kotlin 的地方 —— 这本书不是来给 Lisp 唱赞歌的。

structmatch代数数据类型sealed class穷尽性

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 替你做掉的事:它自动生成 equalshashCodetoStringKotlin 的默认值是对的(data 一加全都有),Racket 的默认值是保守的(你得记得加 #:transparent)。这一局 Kotlin 赢。

规矩:写 struct 就顺手加 #:transparent,除非你明确想要不透明(比如封装内部状态)。

代数数据类型:两种「组合」

「代数数据类型」(ADT)这个名字唬人,其实只有两个概念:

◆ 积类型(AND)与 和类型(OR)
积类型(product)和类型(sum)
含义并且」—— 一个圆有半径并且有颜色或者」—— 一个图形是圆或者是矩形
Racket(struct circle (r color))几个 struct + match 分别处理
Kotlindata 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 弱,那就用纪律补上 —— 而不是假装它不存在。

下一章

我们已经能处理任意形状的有限数据了。

下一章处理无限的。是的,无限 —— 一个包含所有自然数的列表,你可以对它 mapfilter,然后取出前五个。

而这件事的机制,正是 Kotlin 的 Sequence、JS 的 function*、Java 的 Stream 共同的祖先