把「怎么变」和「变什么」拆开
上一章说「怎么攒」可以是一个值。这一章把整条管道——先变换、再过滤、再取前几个——也变成一个值,而且这个值和数据从哪来完全无关。顺带把第 10 章那个「你要 2 个它算 32 个」的毛病治好。
同一件事,两种写法。f 里装了个计数器:
;; 写法一:链式 (take 2 (map f (range 100))) ;; 写法二:转换器 (into [] (comp (map f) (take 2)) (range 100))
两边的结果完全一样。
问:f 分别被调用了几次?
先看数
答案是 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 用 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 一次都没丢。