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

fold 是一切:map 与 filter 的原产地

上一章我们发现,处理列表的函数有一个固定的模板。这一章要做的事很简单,也很暴力:把那个模板本身抽象成一个函数。抽出来的东西叫 fold,而它的威力大得离谱 —— mapfilterlengthreverseappendsummax全都是它的特例,我会一个一个造给你看。顺便,第 1 章题 9 那个「参数顺序为什么是反的」,会自己给出答案。

foldfoldrfoldlreduce抽象

三个函数,一个形状

照第 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) 重复了 → 把 12 挖成参数。

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 当作初始值,然后用 consa 的元素一个个装回去,装完自然就接在 b 前面了。这不是技巧,这是 foldr 的定义直接推出来的。

◆ 所以 fold 到底是什么

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)
Kotlinfold(init) { acc, elem -> }acc 在前!foldRight(init) { elem, acc -> }
JSreduce((acc, elem) => …, init)reduceRight((acc, elem) => …, init)
Haskellfoldl f init xs,f 收 acc elemfoldr f init xs,f 收 elem acc

注意 Racket 的怪异之处:它的 foldl 也是 (elem acc),和 Kotlin/Haskell 的 (acc, elem) 反着来。这是 Racket 为了和 foldr 保持一致做的选择(两者签名统一),代价是和其他语言不一致。写的时候查一下文档,别凭记忆。

而 Kotlin 为什么 foldfoldRight 的顺序反着?因为它反映了展开式里的位置:

// 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 foldO(1) 空间。而且加法/乘法满足结合律,方向无所谓
构造一个新列表且要保持顺序foldr保形。用 foldl 你会得到一个反的
构造新列表,且不在乎顺序(或本来就要反转)foldl省空间
可能提前退出(找到就停)都别用fold 一定会走完整个列表。用 findFirst / first { } / 惰性流(第 12 章)
处理无穷序列只有惰性语言的 foldr第 12 章
⚠ Haskell 用户的著名陷阱(Racket 没有,但值得知道)

在 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 }

判据:如果标准库里有一个名字更准确的函数(sumOfgroupBymaxByOrNullpartitionassociateBy),就用那个。fold 是你在找不到合适名字时的兜底。名字是给读代码的人的,不是给编译器的。

③ 认出 reducefold 的区别 —— 这是个真实的空指针来源。

list.fold(0) { a, b -> a + b }   // 有初始值 → 空列表返回 0,安全
list.reduce { a, b -> a + b }    // 没有初始值 → 空列表直接抛异常!
// Kotlin 提供了 reduceOrNull(),JS 的 reduce 没有初始值时空数组也会 TypeError

「初始值」不是可有可无的装饰 —— 它是「空列表时该返回什么」这个问题的答案。reduce 之所以危险,正是因为它拒绝回答这个问题

下一章

我们已经能优雅地处理列表了。但真实的数据不只是列表 —— 它有形状,有多种情况

下一章:structmatch。你会看到 Kotlin 的 sealed class + when、TypeScript 的判别联合、Rust 的 enum + match,祖籍全在这里。