Home Resources

从 Clojure sequence 到 transducer

Table of Contents

sequence, map, filter & reduce

Clojure 很多算法/函数基于 sequence 定义. sequence 是一个逻辑列表,其接口很简单1

;;; (first coll)
(first '(0 1 2))                        ;=> 0
;;; (rest coll)
(rest '(0 1 2))                         ;=> (1 2)
;;; (cons item seq)
(cons 0 '(1 2))                         ;=> '(0 1 2)

诸如 map, filter 之类的函数默认实现基于 sequence 接口,比如 mapfilter 最天真的实现方式如下:

;;; 这个实现假设你知道
;;; 1. (seq coll) 在 coll 为空返回 nil (是逻辑 false)
;;; 2. nil 是空 sequence, 即 (cons 1 nil) -> '(1)
(defn map [f coll]
  (when (seq coll)
    (cons (f (first coll)) (map f (rest coll)))))

(defn filter [f coll]
  (when (seq coll)
    (if (f (first coll))
      (cons (first coll) (filter f (rest coll)))
      (recur f (rest coll)))))

类似的 reduce 可以实现成:

(defn reduce
  ([f coll]
   (assert (seq coll))
   (reduce f (first coll) (rest coll)))
  ([f val coll]
   (if (seq coll)
     (recur f (f val (first coll)) (rest coll))
     val)))

为什么把 reduce 的实现单独列出来?它特殊的点在于 mapfilter 可以基于它实现:

(defn reverse [coll]
  (reduce (fn [r v] (cons v r)) nil coll))
(defn map [f coll]
  (reduce (fn [r v] (cons (f v) r)) nil (reverse coll)))
(defn filter [f coll]
  (reduce (fn [r v] (if (f v) (cons v r) r)) nil (reverse coll)))

因此,可以说 reduce 是某种更为基础的操作。可以看出 reduce 函数实际为 map 和 filter 这种相对“上层”的操作提供了遍历数据的接口。

既然 reduce 函数可以用于封装对于数据的遍历,我们可以直接定义一个 CollReduce 协议:

;; 取自 clojure.core.protocols
(defprotocol CollReduce
  (coll-reduce [coll f] [coll f val]))

这样我们可以反转下思维模型,我们不是通过 sequence 接口实现了 reduce 函数,而是为 sequence 数据类型实现了 CollReduce 协议2

这个说法的变化不只是文字游戏。观察 sequence 接口能看出 sequence 数据是有序的,你需要通过 (first d), (first (rest d)), (first … (rest d)) 去依次遍历所有条目。而 CollReduce 协议并不隐含这一点,即你可以为底层数据实现更为高效的遍历逻辑(比如并行的遍历)。

Reducing 函数 & Reducer

上面定义了 CollReduce 协议之后,reduce 函数可以直接定义成:

(defn reduce
  [f init coll] (clojure.core.protocols/coll-reduce coll f init))

这样,对于数据的 reduce 依赖于我们对于特定数据给出的 CollReduce 协议实现.

Reducing 函数即上面我们传给 reduce 函数的那个函数,它的形状是:

(f result input) -> new-result

对于 reducing 函数的转换函数(一般用 xf 表示)形状如下:

(xf reducing-fn) -> reducing-fn

正如实现了 sequence 接口 (first, rest, cons) 的数据是 sequence 数据一样,实现了 CollReduce 协议的数据是一个 reducible 的抽象数据类型。对于 sequence, 我们期望 map/filter 等函数返回一个逻辑的集合,对于 reducible 我们也可以对其应用 reducing 函数以得到一个新的 reducible:

(defn reducer
  ([coll xf]
   (reify
    clojure.core.protocols/CollReduce
    (coll-reduce [_ f1 init]
      (clojure.core.protocols/coll-reduce coll (xf f1) init)))))

;; map 基于 sequence 接口 (vector 实现了 sequence 接口) 实现, 应用于 sequence,
;; 得到一个新的 sequence
(reduce + 0 (map inc [1 2 3 4]))

;; reducer 应用于 reducible (vector 实现了 CollReduce 协议), 得到一个新的
;; reducible 数据
(reduce + 0 (reducer [1 2 3 4]
                     (fn [reducing-fn]
                       (fn [result input]
                         (reducing-fn result (inc input))))))

将上面这个针对 reducible 数据每个条目执行特定操作的 reducing 转换函数 (xf) 做一下抽象可以得到:

(defn mapping [f]
  (fn [reducing-fn]
    (fn [result input]
      (reducing-fn result (f input)))))

(reduce + 0 (reducer [1 2 3 4] (mapping inc)))

;;; 针对 reducible 数据的 map 实现:

(defn rmap [f coll] (reducer coll (mapping f)))
(reduce + 0 (rmap inc [1 2 3 4]))

Transducer

Transducer 是给上面提到的 reducing 函数转换函数 (xf) 起的名字3,但是它不止于此,Rich Hickey 在 A History of Clojure4 中说

I think transducers are a fundamental primitive that decouples critical logic from list/sequence processing and construction, and if I had Clojure to do all over I would put them at the bottom.

TODO…

Footnotes:

Author: lotuc, Published at: 2023-12-12 Tue 00:00, Modified At: 2023-12-12 Tue 22:48 (orgmode - Publishing)