fold 是一切:map 与 filter 的原产地
上一章我们发现,处理列表的函数有一个固定的模板。这一章要做的事很简单,也很暴力:把那个模板本身抽象成一个函数。抽出来的东西叫 fold,而它的威力大得离谱 —— map、filter、length、reverse、append、sum、max,全都是它的特例,我会一个一个造给你看。顺便,第 1 章题 9 那个「参数顺序为什么是反的」,会自己给出答案。
三个函数,一个形状
照第 9 章的配方,写三个最普通的列表函数:
(define (sum lst)
(cond [(null? lst) 0]
[else (+ (car lst) (sum (cdr lst)))]))
(define (product lst)
(cond [(null? lst) 1]
[else (* (car lst) (product (cdr lst)))]))
(define (length* lst)
(cond [(null? lst) 0]
[else (+ 1 (length* (cdr lst)))]))
盯着这三个函数看五秒钟。
它们的骨架一模一样。不同的只有两个地方:
- 空表时返回什么(
0/1/0)—— 叫它初始值 - 怎么把「头」和「尾巴的结果」合起来(
+/*/加一)—— 叫它合并函数
那就把这两个地方挖成参数。
(define (foldr f init lst)
(cond
[(null? lst) init] ; ← 空表时返回初始值
[else (f (car lst) ; ← 用 f 合并「头」
(foldr f init (cdr lst)))])) ; 和「尾巴折完的结果」
然后那三个函数就都不用写了:
(define (sum lst) (foldr + 0 lst)) (define (product lst) (foldr * 1 lst)) (define (length* lst) (foldr (lambda (x acc) (+ 1 acc)) 0 lst)) ; ↑ 不看元素,只数个数
抽象不是「把东西变复杂」,抽象是「把重复的部分固定下来,把变化的部分挖成参数」。
你已经对值做过一万次了:(f 1) 和 (f 2) 重复了 → 把 1 和 2 挖成参数。
而 fold 是对控制流做同样的事 —— 把「递归遍历列表」这个行为固定下来,把「怎么合并」挖成参数。
而这件事之所以可能,唯一的原因是:函数可以当参数传(第 6 章)。没有一等函数,就没有 fold。这就是为什么 Java 8 之前,你只能一遍遍手写那个 for 循环。
看着它折
「fold」这个词的意思是「折叠」,而这个比喻是精确的。下面这台机器把折叠的过程一步步展开了 —— 特别注意最后两个例子,它们会把「方向」这件事一次说清。
两个方向:foldr 和 foldl
上面那个 foldr 是非尾递归的 —— 它得先一路走到底,再从里往外算(你在 demo 里看到括号一层层堆起来了)。
另一个方向:带一个累加器,从左往右边走边算(这就是第 8 章的累加器模式):
(define (foldl f init lst)
(cond
[(null? lst) init]
[else (foldl f (f (car lst) init) (cdr lst))]))
; ↑ 尾递归!先把 f 算掉,结果作为新的 init 传下去
两者的区别,一张表说完:
foldr(右折) | foldl(左折) | |
|---|---|---|
| 展开成 | (f 1 (f 2 (f 3 init))) | (f 3 (f 2 (f 1 init))) |
| 初始值在哪 | 最里面 | 最里面(但顺序反了) |
| 递归形式 | 非尾递归 | 尾递归 |
| 空间 | O(n) | O(1) |
| 对列表形状 | 保形 | 反转 |
| 能处理无穷列表吗 | 惰性语言里可以(第 12 章) | 永远不行(要走到底才有结果) |
那个「把 + 换成 cons」的调试技巧
这是我能给你的、关于 fold 最有用的一条建议。
方向记不住的时候,把合并函数换成 cons,初始值换成 '(),跑一遍。因为 cons + '() 恰好就是「重新造一个列表」—— 所以结果的形状,会把 fold 的方向直接画出来:
> (foldr cons '() '(1 2 3)) '(1 2 3) ; ← 原样复制。foldr 保形 > (foldl cons '() '(1 2 3)) '(3 2 1) ; ← 反转了。foldl 反向
为什么?把它展开就看得一清二楚:
(foldr cons '() '(1 2 3)) = (cons 1 (cons 2 (cons 3 '()))) ; ↑ 这正好就是 '(1 2 3) 的定义(第 9 章)—— 所以它是恒等变换 (foldl cons '() '(1 2 3)) = (cons 3 (cons 2 (cons 1 '()))) ; ↑ 每一步都把新元素 cons 到累加器的前面 —— 所以顺序反了
foldr 保形,foldl 反转且省空间。
而且 foldr cons '() 是恒等函数这件事,本身就是一个深刻的提示:foldr 就是「把列表拆开,再用你给的东西重新组装一遍」。你给 cons,它装回原样;你给 +,它装成一个和;你给别的,它装成别的。
把标准库造一遍
现在,用 fold 把你天天用的那些函数全造出来(下面每一行我都在 Racket 里跑过):
(define (my-map f lst) (foldr (lambda (x acc) (cons (f x) acc)) '() lst)) ; ↑ 把 f 作用于头,再 cons 上去 (define (my-filter p lst) (foldr (lambda (x acc) (if (p x) (cons x acc) acc)) '() lst)) ; ↑ 通过就 cons,不通过就跳过 (define (my-length lst) (foldr (lambda (x acc) (+ 1 acc)) 0 lst)) (define (my-reverse lst) (foldl cons '() lst)) ; ← 一行。因为 foldl 天然反转 (define (my-append a b) (foldr cons b a)) ; ← 把 b 当初始值,把 a 拆开重装上去 (define (my-max lst) (foldr max (car lst) lst)) (define (my-count p lst) (foldl (lambda (x acc) (if (p x) (add1 acc) acc)) 0 lst))
七个函数,全都是一行。
my-append 那个尤其漂亮 —— 停下来想三十秒:把 b 当作初始值,然后用 cons 把 a 的元素一个个装回去,装完自然就接在 b 前面了。这不是技巧,这是 foldr 的定义直接推出来的。
fold 是「在列表上做结构化递归」这个模式本身。
第 9 章那个模板 —— 「空表怎么办 / 非空时怎么合并头和尾巴的结果」—— 就是 fold 的两个参数。
换句话说:第 9 章教你的那个「模板」,其实早就被人抽象成一个函数了,它就叫 fold。你以后写列表递归时,十有八九根本不用手写递归 —— 直接 fold。
(这个思想有个更狠的推广:任何递归数据类型,都有一个对应的 fold(术语叫 catamorphism)。树有树的 fold,JSON 有 JSON 的 fold。你为你的 sealed class 写的那个 when 遍历函数,就是那个 fold。)
参数顺序:一个必须小心的陷阱
现在回答第 1 章题 9。但先给你一个警告 —— 不同语言的参数顺序不一样,这是真实的 bug 来源:
| 语言 | 左折的合并函数签名 | 右折的合并函数签名 |
|---|---|---|
| Racket | (foldl f init lst),f 收 (elem acc) | (foldr f init lst),f 收 (elem acc) |
| Kotlin | fold(init) { acc, elem -> } ← acc 在前! | foldRight(init) { elem, acc -> } |
| JS | reduce((acc, elem) => …, init) | reduceRight((acc, elem) => …, init) |
| Haskell | foldl f init xs,f 收 acc elem | foldr f init xs,f 收 elem acc |
注意 Racket 的怪异之处:它的 foldl 也是 (elem acc),和 Kotlin/Haskell 的 (acc, elem) 反着来。这是 Racket 为了和 foldr 保持一致做的选择(两者签名统一),代价是和其他语言不一致。写的时候查一下文档,别凭记忆。
而 Kotlin 为什么 fold 和 foldRight 的顺序反着?因为它反映了展开式里的位置:
// fold: acc 在左边被一路推着走
listOf(1,2,3).fold("") { acc, x -> acc + x }
// = ((("" + 1) + 2) + 3) = "123"
// ↑ acc 一直在左
// foldRight: elem 在左,acc 是「右边折完的结果」
listOf(1,2,3).foldRight("") { x, acc -> x + acc }
// = (1 + (2 + (3 + ""))) = "123"
// ↑ elem 在左,acc 在右
参数顺序不是随手拍的,它是展开式的倒影。一旦你能把展开式写出来,顺序就不用记了。
什么时候该用哪个
| 场景 | 用哪个 | 为什么 |
|---|---|---|
| 求和、计数、求最值(结果是标量) | foldl / Kotlin fold | O(1) 空间。而且加法/乘法满足结合律,方向无所谓 |
| 构造一个新列表且要保持顺序 | foldr | 保形。用 foldl 你会得到一个反的 |
| 构造新列表,且不在乎顺序(或本来就要反转) | foldl | 省空间 |
| 可能提前退出(找到就停) | 都别用 | fold 一定会走完整个列表。用 findFirst / first { } / 惰性流(第 12 章) |
| 处理无穷序列 | 只有惰性语言的 foldr | 第 12 章 |
在 Haskell 里,foldl 因为惰性求值会堆积 thunk:它不立刻算 acc,而是攒一大串「待算的加法」,最后一次性引爆 —— 结果是 O(n) 内存 + 栈溢出。所以 Haskell 程序员被反复告诫:用 foldl'(带撇号的严格版本),不要用 foldl。
Racket 没有这个问题,因为 Racket 是严格求值的 —— (f (car lst) init) 当场就算掉了。Kotlin、JS 同理,都没这个坑。
但这个故事的教训值得记住:惰性不是免费的。它把「什么时候算」这个问题从「显然」变成了「需要推理」,而推理错了的后果是内存爆炸。第 12 章我们会正面处理这笔账。
回流
① 下次写循环之前,问一句:这是不是一个 fold?
// 你常写的
var total = 0L
for (item in items) { total += item.price * item.qty }
// 它是一个 fold
val total = items.fold(0L) { acc, it -> acc + it.price * it.qty }
// 或者更直白:
val total = items.sumOf { it.price * it.qty }
「初始化一个变量 → 循环里更新它 → 循环后用它」这个模式,就是 fold。认出它之后,你会发现自己一半的 for 循环都可以变成一行,而且那个可变变量消失了(第 16 章会讲这为什么值钱)。
② 不要滥用它。这是重要的平衡。fold 表达力太强了,强到你能用它写出任何东西 —— 但「能」不等于「该」。
// ✗ 炫技,难读
val grouped = items.fold(mutableMapOf<String, MutableList<Item>>()) { m, i ->
m.getOrPut(i.category) { mutableListOf() }.add(i); m
}
// ✓ 用那个专门为此而生的函数
val grouped = items.groupBy { it.category }
判据:如果标准库里有一个名字更准确的函数(sumOf、groupBy、maxByOrNull、partition、associateBy),就用那个。fold 是你在找不到合适名字时的兜底。名字是给读代码的人的,不是给编译器的。
③ 认出 reduce 和 fold 的区别 —— 这是个真实的空指针来源。
list.fold(0) { a, b -> a + b } // 有初始值 → 空列表返回 0,安全
list.reduce { a, b -> a + b } // 没有初始值 → 空列表直接抛异常!
// Kotlin 提供了 reduceOrNull(),JS 的 reduce 没有初始值时空数组也会 TypeError
「初始值」不是可有可无的装饰 —— 它是「空列表时该返回什么」这个问题的答案。而 reduce 之所以危险,正是因为它拒绝回答这个问题。
下一章
我们已经能优雅地处理列表了。但真实的数据不只是列表 —— 它有形状,有多种情况。
下一章:struct 和 match。你会看到 Kotlin 的 sealed class + when、TypeScript 的判别联合、Rust 的 enum + match,祖籍全在这里。