结构化递归:一套可以带走的设计配方
这一章最不「Lisp」,但我认为它是全书对你明天的工作最有用的一章 —— 哪怕你读完这本书之后再也不碰 Racket。它要教你的是一件听起来不可能的事:写代码这件事,有很大一部分是可以机械推导出来的,不需要灵感。你只要先把数据的形状写清楚,代码的骨架就会自己长出来。而你之所以常常盯着空白编辑器发呆,往往只是因为跳过了第一步。
一个你可能没意识到的事实
「列表」这个东西,它的定义是递归的:
一个列表,要么是:
- 空的 ——
'() - 或者是「一个元素,接上另一个列表」——
(cons 头 尾)
没有第三种可能。
注意第二条里的「另一个列表」—— 定义在引用它自己。这就是为什么 '(1 2 3) 其实是:
'(1 2 3) = (cons 1 '(2 3)) = (cons 1 (cons 2 '(3))) = (cons 1 (cons 2 (cons 3 '()))) ; ↑ 最后总是那个空表
现在是关键的一跳:
如果数据的定义有两种情况(空 / 非空),那么处理它的函数就必须有两个分支。
如果数据的定义是递归的(尾巴又是一个列表),那么处理它的函数就必须递归调用自己(处理那个尾巴)。
代码的形状,是数据的形状的倒影。它不是想出来的,是抄出来的。
于是,处理任何列表的函数,骨架必然是这样:
(define (处理列表 lst)
(cond
[(null? lst) ...] ; 情况一:空表
[else (... (car lst) ; 情况二:头
(处理列表 (cdr lst)))])) ; 和「尾巴处理完的结果」
这个骨架里只有三个洞。而填这三个洞,比从零开始想「怎么写这个函数」容易一个数量级。
试一遍:求和
(define (sum lst)
(cond
[(null? lst) 0] ; 空表的和 = 0
[else (+ (car lst) (sum (cdr lst)))])) ; = 头 + 尾巴的和
三个洞填完了。而且填的时候,你每次只需要回答一个非常局部的问题:
- 「空表的和是多少?」 → 0。(不需要想递归。)
- 「假设尾巴的和我已经算出来了,怎么得到整个列表的和?」 → 加上头。(不需要想它是怎么算出来的。)
大多数人写递归时会试图在脑子里展开整个调用链:「先调 sum(2 3),它又调 sum(3),它又调 sum()……」—— 然后就晕了。
别这么干。正确的姿势是「递归的信仰之跃」:
假装 (sum (cdr lst)) 已经正确地返回了尾巴的和。别问它怎么做到的 —— 就假设它做到了。然后只回答一个问题:「拿到这个结果之后,我该怎么办?」
这不是自欺欺人。只要 ① 基础情况是对的,② 每一步都在朝基础情况前进,那么归纳法保证了它一定是对的。你不需要在脑子里跑那个循环 —— 数学已经替你跑过了。
完整的设计配方(六步)
HtDP 把这套东西固化成了一个流程。它看起来很啰嗦,但请你至少在真实工作里试三次 —— 你会发现自己盯着空白编辑器发呆的时间大幅减少。
| 步骤 | 做什么 | 为什么 |
|---|---|---|
| 1. 数据定义 | 这个数据有几种形态?每种形态里有什么? | 这一步决定了后面的一切。跳过它 = 后面全靠猜 |
| 2. 签名 + 目的 | 输入什么类型,输出什么类型,一句话说清它干嘛的 | 逼你把「我到底要做什么」说清楚 |
| 3. 例子 | 先写几个具体的输入 → 输出,包括边界情况 | 这就是 TDD 的「先写测试」 —— HtDP 在 2001 年就这么教了 |
| 4. 模板 | 从第 1 步机械地抄出代码骨架 | 整套配方的核心。这一步不需要动脑子 |
| 5. 填空 | 把模板里的洞填上 | 此时你面对的是几个小问题,不是一个大问题 |
| 6. 测试 | 跑第 3 步写的例子 | — |
走一遍完整的。需求:算一个文件树的总大小。
第 1 步:数据定义
; 一个 条目(Entry)要么是: ; - 文件:有名字和大小 ; - 目录:有名字,和一个「条目列表」 ← 注意这里是递归的! (struct file (name size) #:transparent) (struct dir (name kids) #:transparent) ; kids 是 Entry 的列表
停一下,看清楚这个定义里藏了什么:
- 「条目」有两种形态 → 代码会有两个分支
- 目录里含有「条目的列表」→ 会需要两个互相调用的函数(一个处理条目,一个处理列表)
这些结论我还一行代码都没写就已经知道了。这就是配方的价值。
第 2 步:签名 + 目的
; total-size : Entry -> Number ; 算出一个条目(文件或整个目录树)占用的总字节数
第 3 步:例子(先写!)
(require rackunit)
(define fs
(dir "root" (list (file "a" 10)
(dir "sub" (list (file "b" 20)
(file "c" 5)))
(file "d" 1))))
(check-equal? (total-size (file "x" 7)) 7) ; 单个文件
(check-equal? (total-size (dir "empty" '())) 0) ; 空目录 ← 边界!
(check-equal? (total-size fs) 36) ; 10 + 20 + 5 + 1
注意那个空目录。你是在写例子的时候想到它的,不是在写代码的时候 —— 这就是「先写例子」的价值:它逼你在写实现之前面对边界情况。
第 4 步:模板(机械地抄)
; 从数据定义抄:两种形态 → 两个分支
(define (total-size e)
(cond
[(file? e) ... (file-size e) ...] ; 文件里有什么,就写出来
[(dir? e) ... (dir-kids e) ...])) ; 目录里有一个「列表」→ 需要辅助函数
; 从「列表」的定义抄:空 / 非空 → 两个分支
(define (total-size-list lst)
(cond
[(null? lst) ...]
[else ... (total-size (car lst)) ; 头是一个 Entry → 调 total-size
... (total-size-list (cdr lst)) ...])) ; 尾是一个列表 → 调自己
看清楚这一步:我完全没有思考「怎么算文件大小」这个问题。我只是盯着数据定义,把它的形状抄了下来。递归调用出现在哪里,是数据定义告诉我的 —— 「这里有一个 Entry」→ 调处理 Entry 的函数;「这里有一个列表」→ 调处理列表的函数。
第 5 步:填空
(define (total-size e)
(cond
[(file? e) (file-size e)]
[(dir? e) (total-size-list (dir-kids e))]))
(define (total-size-list lst)
(cond
[(null? lst) 0]
[else (+ (total-size (car lst))
(total-size-list (cdr lst)))]))
第 6 步:跑
> (total-size fs) 36 ; ✓ 和第 3 步写的例子一致
你写了一个「处理任意深度嵌套的树形结构」的程序,而且全程没有「思考递归」。
你只是:① 把数据的形状写清楚;② 照着形状抄骨架;③ 填了四个空,每个空都是一个不需要动脑子的局部问题。
那个「互相递归的两个函数」这种听起来很高级的结构,是数据定义直接推出来的,不是你想出来的。
为什么你在工作里常常写不出来
诚实地说:你在真实工作里,八成不会走这六步。你会直接开始写。
而当你卡住的时候,八成是因为你跳过了第 1 步。
「这个函数怎么写」这个问题之所以难,往往是因为你还没搞清楚「我在处理的到底是个什么形状的东西」。而一旦你把数据的形状写清楚 —— 它有几种情况?每种情况里有什么?哪些字段又是复合结构?—— 代码骨架就没什么可想的了。
下次你盯着编辑器发呆的时候,试试关掉编辑器,先在纸上写清楚:
- 输入有几种形态?(这决定了你有几个分支)
- 每种形态里有什么字段?(这决定了你能用什么)
- 哪个字段的类型「又是它自己」或者「是它的列表」?(这决定了递归调用出现在哪里)
回流:你的语言里,这套配方叫什么
Kotlin 的 sealed class 和 when,就是这套配方的直接对应物 —— 而且编译器还帮你检查了「分支有没有漏」。
// 第 1 步:数据定义(有几种形态?)
sealed interface Entry
data class MyFile(val name: String, val size: Long) : Entry
data class Dir(val name: String, val kids: List<Entry>) : Entry
// ↑ 递归!
// 第 4 步:模板 —— IntelliJ 会帮你生成所有分支(Alt+Enter → add remaining branches)
fun totalSize(e: Entry): Long = when (e) {
is MyFile -> TODO()
is Dir -> TODO()
}
// ↑ 如果你漏了一个分支,编译器直接报错(when 用作表达式时必须穷尽)
// 第 5 步:填空
fun totalSize(e: Entry): Long = when (e) {
is MyFile -> e.size
is Dir -> e.kids.sumOf { totalSize(it) } // 列表 → sumOf 替你做了递归
}
三个直接可用的推论:
① when 的穷尽性检查,是配方第 4 步的编译器版本。这就是为什么你永远应该把 when 用作表达式(val x = when(...))而不是语句 —— 只有当作表达式时,Kotlin 才强制要求穷尽。当语句用,漏个分支它就默默放过了。这是一条能直接写进团队规范的规则。
② TypeScript 的判别联合是同一件事:
type Entry =
| { kind: "file"; name: string; size: number }
| { kind: "dir"; name: string; kids: Entry[] }; // ← 递归
function totalSize(e: Entry): number {
switch (e.kind) {
case "file": return e.size;
case "dir": return e.kids.reduce((a, k) => a + totalSize(k), 0);
}
}
// 想要穷尽检查?加一个 default: const _x: never = e; —— 漏分支时类型报错
③ 「先写例子」= TDD。HtDP 在 2001 年就把「先写例子,再写实现」写进流程了,比 TDD 流行还早。但它给的理由比 TDD 更好:不是「为了有测试」,而是「为了逼你在写实现之前,把边界情况想清楚」。那个空目录的例子,你是在写测试时想到的 —— 这才是它的价值。
这一章的一句话
你不是在「设计代码」,你是在「阅读数据的形状,然后把它抄下来」。
灵感是稀缺的,而配方不是。把需要灵感的部分压缩到最小 —— 这就是工程和艺术的区别。
下一章,我们会发现:连这个「模板」本身,都可以被抽象掉。它有一个名字,你天天在用它:fold。