卷 III · 懒CH 12深度 12/24

把「怎么变」和「变什么」拆开

上一章说「怎么攒」可以是一个值。这一章把整条管道——先变换、再过滤、再取前几个——也变成一个值,而且这个值和数据从哪来完全无关。顺带把第 10 章那个「你要 2 个它算 32 个」的毛病治好。

转换器2 次 vs 32 次3 个中间集合 vs 0 个

▷ 先猜一下

同一件事,两种写法。f 里装了个计数器:

;; 写法一:链式
(take 2 (map f (range 100)))

;; 写法二:转换器
(into [] (comp (map f) (take 2)) (range 100))

两边的结果完全一样。

问:f 分别被调用了几次?

A 都是 2 次——都只要 2 个结果 B 都是 32 次——都受分块影响 C 链式 32 次,转换器 2 次 D 链式 100 次,转换器 2 次

先看数

答案是 C:32 次 和 2 次

为什么?链式写法里,map 必须先产生一个序列交给 take, 而它产生序列的方式是第 10 章说的那样——一次一块,32 个。 take 拿到这一块之后说「够了我只要 2 个」,但那 32 次已经发生了。

转换器写法里没有中间序列。三步被压成一个归约函数, 元素一个一个穿过去:进来一个,map 变换它,take 数一下, 够了就当场 reduced 喊停。第 2 个元素之后,遍历结束。

它是怎么做到的

回到第 11 章末尾那个 twice-rf接收一个归约函数,返回一个新的归约函数。 转换器就是这个形状,没有别的。

;; 这就是 map 的转换器版本,去掉了所有细节之后
(defn mapping [f]
  (fn [rf]                          ;; 接收一个归约函数
    (fn [acc x]                     ;; 返回一个新的归约函数
      (rf acc (f x)))))             ;; 把 x 变换一下再交给下游

;; filter 的
(defn filtering [pred]
  (fn [rf]
    (fn [acc x]
      (if (pred x) (rf acc x) acc)))) ;; 不合格就原样返回,等于「跳过」

关键在于:这两个函数里完全没有提到集合。 没有 first,没有 rest,没有 conj, 没有「结果放哪里」。它们只描述了「一个元素来了该怎么办」。

所以「往哪儿放」就成了调用时才决定的事:

(def xf (comp (map inc) (filter even?)))   ;; 一个值,可以存起来、传出去

(into [] xf (range 10))          ;; => [2 4 6 8 10]    放进向量
(into #{} xf (range 10))         ;; => #{2 4 6 8 10}   放进集合
(transduce xf + 0 (range 10))    ;; => 30              直接加起来
(sequence xf (range 10))         ;; => (2 4 6 8 10)    要个惰性序列

同一个 xf,四种去处。在链式写法里, (->> coll (map inc) (filter even?)) 这一串 永远只能产生序列,因为「怎么走集合」被写死在里面了。

◆ 可以带走的判断

转换器最值钱的不是省了几次调用,是它和数据源、数据去处都无关。 同一个 xf 可以喂给向量、喂给 channel、喂给一个网络流—— 「怎么变」终于和「变什么」分开了

顺手说一句 comp 的方向

有一个地方一定会绊你一下:

;; 普通函数的 comp:从右往左
((comp inc double) 5)      ;; => 11    先 double 再 inc

;; 转换器的 comp:从左往右
(into [] (comp (map inc) (filter even?)) (range 5))
;;                ↑ 先 map     ↑ 后 filter

看起来矛盾,其实一致。转换器组合的是「改造归约函数」这个动作, 而不是数据变换本身。(comp a b) 的意思是「先用 a 改造,再用 b 改造」, 改造是从外往里包的,于是数据流动起来就成了从左往右。

记不住也没关系,记住结论就行:转换器里的 comp 读起来和管道顺序一致, 和你写 ->> 的顺序一样。

那还要不要用 ->> ?

要。老实说一下什么时候用哪个:

场景                              用哪个
─────────────────────────────────────────────────────
一般的数据处理,几百几千条          ->>   ← 读起来最舒服,就用它
要复用同一套处理逻辑                转换器  它是个值,可以存
数据量大 / 在热点路径上             转换器  没有中间集合
数据源不是集合(channel、流)        转换器  ->> 根本用不了
需要提前退出且步骤里有副作用        转换器  链式会多算一块(第 10 章)
只是想写得好看                      ->>   别为了用而用

这一点值得强调,因为转换器很容易被当成「更高级的写法」而滥用。 它不是 ->> 的升级版,是解决另一个问题的东西。 九成的日常代码用 ->> 更清楚。

中间集合的账

再算一笔更直观的账。一千条数据走三步:

# 链式(急切版本,mapv/filterv)
(->> data (mapv f) (filterv p) (mapv g))

  mapv  → 造一个 1000 元素的向量
  filterv → 造一个 ~500 元素的向量
  mapv  → 造一个 ~500 元素的向量
  ─────────────────────────────
  中间集合 3 个,中间元素合计 ~2000 个

# 转换器
(into [] (comp (map f) (filter p) (map g)) data)

  中间集合 0 个

数据量小的时候这不重要。数据量大、或者这段代码在每秒跑一万次的路径上时, 这就是全部的差别——分配和垃圾回收的压力少了一大截。

☕ 写 Java 的你

Java 用 Collector 解决了同一个问题的一半。 Collectors.mapping(f, Collectors.toList()) 也是「可组合的处理逻辑」, 而且它也有第 11 章说的那个 finisher—— 这正是转换器需要「第三样东西」的原因:结束时收尾 (比如 partition-all 要把最后不满一组的那些吐出来)。

两条路的差别在于方向:

  • Collector 描述的是「怎么收集结果」, 绑在 Stream 这一端。
  • 转换器描述的是「元素来了怎么办」, 两端都不绑——所以它能用在 core.async 的 channel 上, 而 Collector 没法用在一个 BlockingQueue 上。
▸ 在现实里
  • core.async 的 channel(chan 10 xf) —— 同一个 xf,现在作用在一条并发管道上。 这是转换器最初被造出来的动机:Rich Hickey 发现 他要为 channel 再写一遍 map / filter,于是决定不写了。
  • Unix 管道grep | awk | head 也是一个「不产生中间文件」的管道, 而且 head 拿够了就关掉上游(SIGPIPE)—— 和 reduced 是同一件事。
  • Kafka Streams / Flink:算子拓扑和数据源分离, 同一套处理逻辑可以跑批也可以跑流。同一个思路,工业规模。
✗ 这个直觉是错的

「转换器就是性能优化,所以能用的地方都应该用, 写起来无非是把 ->> 换成 comp。」

性能只是副产品。它真正解决的是 「处理逻辑」和「数据源/去处」之间的耦合。 如果你的代码只处理内存里的小集合、也不复用这段逻辑, ->> 更清楚,硬换转换器是在给读代码的人加负担。

还有一个具体的坑:有状态的转换器不能共享着并发用(take 2)(distinct)(partition-all 3) 内部都有状态,一个转换器实例被两个地方同时消费会串味。 (map f) 这类无状态的则没问题。

◇ 另存一版

答案是 C:链式 32 次,转换器 2 次。

A 「都是 2 次」——这是假设 take 能往上游传话说「我只要 2 个」。 链式写法里传不了:map 已经把那一块交出来了,它不知道下游要几个。

B 「都是 32 次」——这是以为分块无可避免。 分块是序列的性质,而转换器路径上根本没有中间序列, 所以它不受分块影响。

D 「链式 100 次」——那是完全不惰性的情况。 链式还是懒的,只是懒的粒度是 32。

你进来时:map / filter 是「对集合做的操作」, 一串管道就是一串集合变换。

你出去时:它们描述的是「一个元素来了怎么办」, 和集合无关;把这些描述组合起来仍然是一个描述, 直到你告诉它数据从哪来、结果放哪去,才真的开始干活。

第 10 章的分块结论完全成立(链式那 32 次就是它), 第 11 章的归约函数是这一章的原材料。 这一章只重建了一条路径:管道本身也可以是一个值

这一章的一句话

转换器把「怎么变」从「变什么」里拆出来,于是同一段处理逻辑 可以喂给向量、集合、数字、channel——顺带地,中间集合从 3 个变成 0 个。

卷 III 结束。卷 IV 转向这本书最实用的部分:并发。 前面十二章反复说「值不会变」,下一章要把这句话换算成一个具体的数字: 四个线程各加 25 次,可变计数器丢掉了 64 次更新, 而同一个交错下,atom 一次都没丢。