reduce 才是本体
你大概觉得 reduce 是「求和的花哨写法」。这一章想让你换一个看法:map、filter、group-by、frequencies——你用过的几乎所有序列函数,骨架都是同一个,而认出这件事之后,下一章那个把戏才有地方落脚。
下面五件事,看起来完全不一样:
求和 [3 1 4] → 8
最大值 [3 1 4] → 4
装进向量 [3 1 4] → [3 1 4]
按奇偶分组 [1 2 3 4] → {false [1 3], true [2 4]}
出现次数 [:a :b :a] → {:a 2, :b 1}
问:如果只允许用一个函数把这五件事全部写出来,需要给这个函数传几样东西?
同一个骨架
答案是 B(C 也说得通,第 12 章会解释为什么要加第三样)。
那个函数叫 reduce:
(reduce + 0 [3 1 4]) ;; => 8
(reduce max [3 1 4]) ;; => 4
(reduce conj [] [3 1 4]) ;; => [3 1 4]
(reduce (fn [m x] (update m (even? x) (fnil conj []) x))
{} [1 2 3 4]) ;; => {false [1 3], true [2 4]}
(reduce (fn [m x] (update m x (fnil inc 0)))
{} [:a :b :a]) ;; => {:a 2, :b 1}
五件事,一个函数,两个参数。变的只有「怎么把新的一个并进已有的结果里」。
这个骨架有个名字:
归约函数(reducing function):形状是
(acc, x) → acc 的函数。
「拿到目前为止攒的东西和新来的一个,给我新的『目前为止』。」
这个形状之所以重要,是因为它不关心数据从哪来: 向量、文件、网络流、消息队列,只要能一个一个地喂给它,它就能干活。 「怎么攒」和「从哪儿来」被彻底分开了。
它和 for 循环差在哪
先写一个 Java 的版本:
int sum = 0;
for (int x : xs) {
sum += x; // ← 「怎么攒」被焊死在循环体里
}
这段代码里,「遍历」和「怎么攒」长在一起。 结果是:「怎么攒」这件事没法单独拿出来—— 你不能把它存进变量、传给别人、和另一个「怎么攒」拼起来。 想换一种攒法,你得再写一个循环。
而 reduce 把它变成了一个值:
(def sum-rf +) ;; 「怎么攒」是一个值,可以命名 (def collect-rf conj) ;; 另一个 (reduce sum-rf 0 xs) (reduce collect-rf [] xs) (defn twice-rf [rf] ;; 甚至可以写一个「改造攒法」的函数 (fn [acc x] (rf acc (* 2 x)))) (reduce (twice-rf +) 0 [1 2 3]) ;; => 12
最后那个 twice-rf 请多看两眼:
它接收一个归约函数,返回一个新的归约函数。
这就是转换器,第 12 章的全部把戏就是把这个想法做正规。
for 循环把「遍历」和「怎么攒」焊在一起;reduce 把它们拆开, 于是「怎么攒」变成了一个可以命名、传递、组合的值。 能组合这一点,是下一章的地基。
怎么提前停下来
循环有 break,reduce 有 reduced:
(reduce (fn [acc x]
(if (> x 3)
(reduced acc) ;; ← 包一层,reduce 当场停手
(conj acc x)))
[] [1 2 3 4 5])
;; => [1 2]
把返回值用 reduced 包一下,reduce 就不再往下遍历了。
这不只是个便利:它是无限序列能被 reduce 的原因,
也是第 12 章 take 能在管道中间喊停的机制。
顺带解决一个常见困惑:既然有了 reduce,
为什么还要 map 和 filter?
;; 用 reduce 写 map (reduce (fn [acc x] (conj acc (f x))) [] xs) ;; 用 reduce 写 filter (reduce (fn [acc x] (if (pred x) (conj acc x) acc)) [] xs)
能写,但读起来累,而且意图被埋在细节里。
map 和 filter 的价值是说清楚你在干什么:
一个是「每个都变一下,个数不变」,一个是「挑一些出来,值不变」。
这两句承诺在优化和推理时都用得上。
所以正确的关系是:reduce 是底座,map / filter 是长在它上面的、 有明确语义的常用形状。
Stream.reduce 是同一件事,但被类型系统压得有点扁:
// 想把元素装进 List,reduce 的签名会逼你写这个
xs.stream().reduce(new ArrayList<>(),
(acc, x) -> { acc.add(x); return acc; }, // 累加器
(a, b) -> { a.addAll(b); return a; }); // 合并器(并行才用得上)
于是 Java 给了你 Collector,把这三样东西打包
(supplier / accumulator / combiner,再加一个 finisher)。
Collectors.groupingBy、Collectors.toList 就是这么来的。
记住 Collector 这个东西——它和第 12 章的转换器解决的是同一个问题,
走的却是两条不同的路。到那一章我会把两者摆在一起比。
- MapReduce:名字里那个 reduce 就是这个。 之所以能把活分到一千台机器上,正是因为「怎么攒」被表达成了一个独立的函数, 而不是长在循环体里。
- Redux / 状态管理:
(state, action) → state—— 逐字就是归约函数的形状。整个应用的状态就是把所有 action 归约一遍的结果。 - 银行对账:期初余额 + 一串流水 → 期末余额。 这是归约的原型,比计算机早了几百年。
「reduce 就是把一堆东西缩成一个值, 所以它只适合求和、求最大值这类『多变少』的场合。」
reduce 的结果可以比输入更大。
(reduce conj [] xs) 的结果和输入一样大;
分组、建索引的结果都是复杂结构。
「reduce」这个名字确实误导人——
它叫 fold(折叠)会准确得多:
把一个序列折成一个东西,而那个东西可以是任何东西。
这也是为什么 Haskell 那边管它叫 foldl / foldr。
答案是 B:一个初始值 + 一个 (acc, x) → acc 的函数。
(C 那个「收尾函数」在下一章会补上,它是转换器的第三个必需件。)
A 「本质不同」——它们的意图确实不同, 但骨架相同。认出骨架相同,才能写出对五种情况都成立的工具。
D 「只要合并函数,初始值用第一个元素」——
(reduce max [3 1 4]) 确实可以这样。
但对「装进向量」这种结果类型和元素类型不一样的情况就不行了:
第一个元素是 3,而你要的起点是 []。
所以初始值必须能单独给。
你进来时:reduce 是求和的花哨写法,日常用 map 和 filter 就够。
你出去时:reduce 是底座,map / filter 是长在上面的常用形状; 而「怎么攒」是一个可以命名、传递、组合的值。
前两章的惰性、分块结论完全不动。 这一章只重建了一条路径:序列函数之间的关系—— 它们不是并列的一堆 API,是一个底座加几个形状。
这一章的一句话
reduce 把「怎么攒」从循环体里挖出来,变成一个值。 能被命名、能被传递、能被组合。
下一章把最后那个词兑现。既然「怎么攒」是值, 那「先变换再过滤再变换」这一整套也应该是一个值, 而且应该和数据源没关系。做到这一点之后, 第 10 章那个「你要 2 个它算 32 个」的毛病会当场消失: 同样的活,同样的结果,函数调用从 32 次降到 2 次。