卷 III · 懒CH 11深度 11/24

reduce 才是本体

你大概觉得 reduce 是「求和的花哨写法」。这一章想让你换一个看法:map、filter、group-by、frequencies——你用过的几乎所有序列函数,骨架都是同一个,而认出这件事之后,下一章那个把戏才有地方落脚。

归约函数reduced 早停把「怎么攒」变成值

▷ 先猜一下

下面五件事,看起来完全不一样:

求和        [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}

问:如果只允许用一个函数把这五件事全部写出来,需要给这个函数传几样东西?

A 做不到,它们本质上是不同的操作 B 两样:一个初始值,一个「拿旧的和新的、给我新的」的函数 C 三样:初始值、合并函数、还要一个结束时的收尾函数 D 一样:只要那个合并函数,初始值可以用第一个元素

同一个骨架

答案是 BC 也说得通,第 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, 为什么还要 mapfilter

;; 用 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)

能写,但读起来累,而且意图被埋在细节里。 mapfilter 的价值是说清楚你在干什么: 一个是「每个都变一下,个数不变」,一个是「挑一些出来,值不变」。 这两句承诺在优化和推理时都用得上。

所以正确的关系是:reduce 是底座,map / filter 是长在它上面的、 有明确语义的常用形状。

☕ 写 Java 的你

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.groupingByCollectors.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 次。