STATION II · 干流CH 05里程 05/20

递归与 fold:一切循环的祖先

Haskell 没有 for,没有 while,没有 break。第一次听说的人以为这是苦行,其实是分工:循环在这里不是语法,是「库函数」——而几乎所有这类库函数,都是同一位祖先的后代。这一章从手写递归出发,亲手把那个祖先——fold——从重复代码里「抽」出来,然后你会突然认出它:reduce、Stream.collect、sum、reverse、甚至上一章的模式匹配遍历,全是它换的马甲。

递归foldrfoldlreduce 血缘

数据是递归的,函数就是递归的

上一章末尾露过列表的真身:data List a = Nil | Cons a (List a)——空,或者头加尾。处理一个递归定义的数据,最自然的函数形状就是照着它的两个构造子各写一个分支:

sum' :: [Int] -> Int
sum' []       = 0                -- Nil 分支:空列表的和
sum' (x:xs)   = x + sum' xs      -- Cons 分支:头 + 尾的和

length' :: [a] -> Int
length' []     = 0
length' (_:xs) = 1 + length' xs

product' :: [Int] -> Int
product' []     = 1
product' (x:xs) = x * product' xs

写到第三个,任何工程师的重复代码警报都该响了:三个函数唯二的区别是「空列表给什么」和「头和尾的结果怎么合并」。把这两个可变点抽成参数,重复就死了:

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr f z []     = z
foldr f z (x:xs) = f x (foldr f z xs)

sum'     = foldr (+) 0
product' = foldr (*) 1
length'  = foldr (\_ n -> 1 + n) 0

这就是 fold(折叠)。它不是被「发明」的,是从重复里被挤出来的——你刚才目睹了整个过程。这个抽取手法本身(把递归骨架和业务逻辑分离)比 fold 更值钱,它是所有高阶函数的出生方式:map 是从「逐个变换」的重复里挤出来的,filter 是从「按条件挑选」里挤出来的。下次你在业务代码里闻到「第三次写同构循环」的味道,就是动手挤一个的时机。

λ 源头碑 · foldr 的一句话直觉

列表 [1,2,3] 的真实结构是 1 : (2 : (3 : []))foldr f z 做的事,是把每个 : 换成 f,把结尾的 [] 换成 z:1 `f` (2 `f` (3 `f` z))。仅此而已。用这个直觉秒验:foldr (:) [] xs 把 : 换成 :、[] 换成 []——所以是恒等,原样复制列表。✓

括号往哪边长:foldr vs foldl

fold 有两个方向。foldr(right)括号向右长;foldl(left)带一个累加器从左往右扫,括号向左长。口说无凭,下面的观测台把每一步展开摆给你看——先用 (+) 看结构,再切到 (-) 看两个方向结果真的不同(这也是第 2 章泅渡题的答案:foldr 得 2,foldl 得 -6):

盯着 foldl 的展开多看一眼:它一路只是把表达式越攒越大,走完整个列表之前一个加法都没做。在严格求值的语言里这没什么;在惰性的 Haskell 里,这个「攒」是有实体的——每层括号是一个真实的 thunk,堆在内存里。这里先按下不表,第 16 章空间泄漏专题连本带利算这笔账。

▸ 下游对照 · reduce 家族认亲

Kotlin:fold(initial) { acc, x -> ... } 就是 foldl(名字都没改);reduce 是没有初始值的变体。Java:stream.reduce(identity, accumulator),同样是左折叠。JS:Array.prototype.reduce。Python:functools.reduce。注意一个方向性事实:下游抄走的几乎全是 foldl,因为严格求值语言里它能写成循环、O(1) 栈。foldr 在下游的对应物是「递归 + 后处理」,很少有直接封装——这恰好说明 foldr 的价值依赖惰性,马上讲。

全家福:map 和 filter 也是 fold 的孩子

map f    = foldr (\x acc -> f x : acc) []
filter p = foldr (\x acc -> if p x then x : acc else acc) []
reverse  = foldl (flip (:)) []
maximum (x:xs) = foldl max x xs

练习一下源头碑直觉,验证第一行:map f 把 1 : (2 : []) 的每个 : 换成「先 f 再 :」、[] 换成 [],得到 f 1 : (f 2 : [])。✓ 理论上列表库的所有函数都能用 fold 写——这有个正式说法:fold 是列表的「消去规则」,列表携带的全部信息,fold 都能取出来。实践中当然直接用现成的 map/filter,但知道它们同源,你对「这堆 API 会不会组合出问题」的直觉会准很多。

scanl:留下足迹的 fold

fold 的一个近亲值得单独介绍。scanl 和 foldl 做完全相同的事,区别是把每一步的中间结果都留下来:

λ> foldl (+) 0 [3,5,2,7]
17
λ> scanl (+) 0 [3,5,2,7]
[0,3,8,10,17]

「运行余额」「累计销量」「前缀和」——所有报表类需求的核心原语就是它。配合第 7 章的惰性,scanl 还能扫无限流:scanl (+) 0 [1..] 是全体三角形数的无限列表。Kotlin 1.4 加入的 runningFold、SQL 的窗口函数 SUM() OVER (...),认亲成功。

fold 与 scan 的关系值得咀嚼一句:fold 报告终点,scan 报告全程。调试一个行为怪异的 fold 时,把它临时换成同参数的 scan,中间每一步立刻摊在眼前——这是比打印累加器优雅得多的排障法,在 Kotlin 里把 fold 换成 runningFold 同理适用,值得写进肌肉记忆。

真实代码:一趟遍历,多个指标

实战里最常见的 fold 场景:对同一批数据同时算多个统计量。新手写法是遍历三次(sum 一次、count 一次、max 一次);fold 思维是把累加器做成积类型,一趟全算完:

data Stats = Stats { total :: Int, count :: Int, biggest :: Int }

step :: Stats -> Int -> Stats
step (Stats t c b) x = Stats (t + x) (c + 1) (max b x)

summarize :: [Int] -> Stats
summarize = foldl' step (Stats 0 0 minBound)

-- λ> summarize [12, 7, 45, 3]
-- Stats {total = 67, count = 4, biggest = 45}

三个注意点。其一,累加器是上一章的积类型——ADT 和 fold 在真实代码里几乎总是成对出现。其二,用的是带撇号的 foldl'(严格版),原因上面刚讲过。其三,这个模式直接平移到你的语言:Kotlin 的 fold(Stats(0,0,Int.MIN_VALUE)) { acc, x -> ... }、Java Stream 的 reduceCollectors.teeing,一趟代替三趟,对大数据量就是三倍吞吐差距。

顺带把视野再抬高一档:fold 不是列表的专利。任何「有结构的容器」都能折叠——树、Map、Maybe 都行,Haskell 把这层抽象叫 Foldable(sum/length/elem 的真实签名都是 Foldable t => t a -> …,所以它们对树和 Map 照用)。你只需要在心里留个钩子:「遍历并归约」是结构无关的能力,第 9 章的 Functor 会把「遍历并变换」也抽象成同款。

foldr 的独门绝技:处理无限列表

看这个表达式,先别跑,猜猜会不会死循环:

λ> foldr (&&) True (map even ([2,4] ++ [5..]))   -- 后面跟着无限列表!
False

foldr 展开第一步是 even 2 && foldr (&&) True (…剩下的)。而 && 是惰性的:左边一旦是 False,右边根本不看。所以折到 5(第一个奇数)就短路返回,无限列表毫发无伤。foldl 做不到——它必须先走完列表才开始算,而无限列表走不完。规则由此得出:

标准库里这个家族人丁兴旺,认清族谱免得选错工具——顺手给出每位成员「体内是哪个 fold」:

函数作用体内是能吃无限列表?
sum / product求和 / 求积foldl'(严格规约)
and / or全真 / 有真foldr(短路)
any p / all p存在 / 任意满足foldr(短路)
concat / concatMap拍平 / 变换后拍平foldr(惰性拼接)
maximum / minimum最值foldl' 一族
length / elem计数 / 查找严格走脊椎 / 短路否 / 找到就能停

规律一眼可见:需要看完全部数据的,底下是 foldl';可能提前收工的,底下是 foldr。这张表同时是下一节选型口诀的实证。

◆ 水文记录 · fold 选型口诀

能短路、要处理无限流、结果还是惰性结构(比如 map)→ foldr;老老实实把整条有限列表规约成一个值(求和、计数)→ foldl'(注意撇号,严格版,第 16 章)。裸 foldl 几乎永远不是正确答案——这是 Haskell 社区用二十年空间泄漏换来的口诀,先背下来,第 16 章给证明。

⚠ 暗礁 · 递归不会爆栈吗?

Java 写 10 万层递归就 StackOverflowError,Haskell 为什么敢拿递归当日常?两个原因。其一,GHC 做尾调用优化:尾位置的递归调用编译成跳转,不长栈。其二(更本质),惰性改变了「栈」的形状——foldr 构造惰性结构时,递归发生在 thunk 里,消费多少展开多少,天然是流式的。但这不是免死金牌:非尾递归 + 严格规约(foldl 的 thunk 链)照样能压爆栈,你会在第 16 章的观测站亲眼看到浏览器栈被压爆的样子。「递归安全」的真实条件,是理解求值顺序,而不是语言魔法。

▸ 下游对照 · 带回去的思维件

下次在 Kotlin 里写 var acc = ...; for (x in xs) { acc = f(acc, x) },停一秒:这就是 fold,写成 xs.fold(init, ::f) 少两行、少一个可变变量、少一类「循环里顺手改了别的东西」的 bug。更广的收获是那个抽取手法:业务代码里第三次出现同构的遍历时,把骨架抽成高阶函数——你团队的 utils 包里最好用的那几个函数,大概率都是这么来的。

✚ 动手泅渡 · 回第 2 章的终端做

1. 用源头碑直觉笔算 foldr (:) [1000] [1,2,3],再用终端验证。

2. 只用 foldr 实现 any :: (a -> Bool) -> [a] -> Bool,并思考:你的实现遇到 any even [1..] 会终止吗?为什么?

3. 在观测台把 op 切到 (-)、模式切到 foldl,写出每一步的括号,解释 -6 是怎么来的。

4. 用 scanl 写「账户流水」:初始余额 1000,交易列表 [-200, 500, -80],求每笔交易后的余额序列。再想想:哪个下标的值就是 foldl 的结果?

5. 一趟统计实战:仿照 Stats 的写法,一趟 fold 同时算出列表的「偶数个数」和「奇数之和」。累加器用什么类型?

6. 挑战:foldr (\x k -> k . (x:)) id [1,2,3] [] 的值是什么?(这是用 foldr 写 foldl 的经典魔术,想不通没关系,记下来第 12 章 State 讲完回来看。)