卷 II · 手上的活CH 08深度 8/24

一个接口,跑遍所有容器

向量、列表、字典、集合、字符串、range、还有还没算出来的惰性序列——同一组函数对它们全部有效,而它们之间没有继承关系。这一章讲那个把它们串起来的东西,它只有两个方法。

seq 抽象nil 与空的区别为什么不是继承

▷ 先猜一下
(rest  [1])      ;; A
(next  [1])      ;; B
(seq   [])       ;; C

这三行分别返回什么?

A 三个都是 nil B 三个都是 () C rest 给 (),next 给 nil,seq 给 nil D rest 给 nil,next 给 (),seq 给 ()

这道题看起来是抠细节,其实问的是:「空」和「没有」是不是同一件事?

两个方法,全部家当

Clojure 里所有序列操作都建立在两个问题上:

first  —— 给我第一个
rest   —— 给我剩下的

就这两个。mapfilterreducetakepartition——几百个序列函数,全部可以只用这两个方法写出来。

所以一个东西要能被这几百个函数处理,需要满足的条件是: 它能回答「给我第一个,和剩下的」。就这么多。 不需要继承某个基类,不需要实现十个方法,不需要是集合。

看那张表:七种完全不同的东西,同一组函数全部有效。 而它们在 Java 世界里的对应物(ListMapStringIntStream)互相之间毫无关系, 你得为每种写不同的遍历代码。

关键在于它是「问」,不是「是」

这是这一章真正的点,值得慢一点讲。

Java 的做法是「是什么」ArrayList 是一个 List是一个 Collection是一个 Iterable。 能力来自继承树上的位置。于是:

  • 一个类型要获得这些能力,必须在设计时就接进这棵树。
  • String 没接进去,所以它不是 Collection, 你没法直接对它 stream() 出字符。
  • Map 接的是另一棵,所以 Map 不是 Collection—— 你得先 entrySet() 才能遍历。

Clojure 的做法是「能不能」seq 是一个函数, 你把东西丢给它,它想办法给你一个「能回答 first/rest 的东西」。

(seq [1 2 3])      ;; => (1 2 3)
(seq {:a 1})       ;; => ([:a 1])          字典 → 键值对的序列
(seq "ab")         ;; => (\a \b)           字符串 → 字符的序列
(seq #{1 2})       ;; => (1 2)
(seq (range 3))    ;; => (0 1 2)

字典不需要「是」一个集合,它只需要在被问到时能给出一串键值对。 这个转换发生在调用点,而不是定义点—— 所以任何东西都能事后接进来,包括不是你写的类型。 第 17 章会把这个思路推到极致。

◆ 可以带走的判断

继承是在定义的时候决定「我属于哪一类」; seq 是在使用的时候问「你能不能干这件事」。 后者的可扩展性高得多,代价是编译期少了一层检查。

顺手解决一个每天都会遇到的困惑

为什么 (map f {:a 1 :b 2}) 里的 f 收到的是 [:a 1] 这样一个向量?

因为字典的 seq 是「键值对的序列」,而每个键值对本身就是一个长度 2 的向量。 于是所有向量的操作对它都成立,包括解构:

(map (fn [[k v]] (str (name k) "=" v)) {:a 1 :b 2})
;; => ("a=1" "b=2")
;;      ↑ 这里用的就是第 7 章的向量解构

(into {} (map (fn [[k v]] [k (* v 10)]) {:a 1 :b 2}))
;; => {:a 10, :b 20}       改所有的值

没有 Map.Entry,没有 getKey()/getValue()。 键值对就是一个两元素的向量,你已经会拆它了。

「空」和「没有」

现在回答开头那道题。答案是 C

(rest [1])   ;; => ()      空序列
(next [1])   ;; => nil     没有了
(seq  [])    ;; => nil     没有了

Clojure 在这里做了一个明确的区分,而这个区分是有用的:

  • rest 永远返回一个序列(可能是空的)。 方便你安心地链下去,不用判空。
  • seqnext 在没东西时返回 nil。 而 nil 在条件里是假——这让「还有没有」变成一个直接能用的判断。

于是遍历的惯用写法长这样:

(when-let [s (seq coll)]      ;; 有东西才进去
  (println (first s))
  (recur (rest s)))

这也是为什么 (empty? coll) 的实现就是 (nil? (seq coll)): 「空」被定义成「问它要序列,它说没有」。

✎ 术语正名

集合(collection)序列(sequence)是两样东西, 这本书后面会一直区分:

集合是一个具体的数据结构:向量、字典、集合。它有身份、有大小、能存。

序列是「按顺序把元素给出来」这件事的抽象, 它可能根本没有对应的存储——(range 1e9) 是一个序列, 但内存里没有十亿个数字。

seq 这个函数的工作,就是从集合造出一个序列的视图。 卷 III 整卷讲的都是这个视图能有多懒。

▸ 在现实里

这个思路你在别处见过:

  • Java 的 Streamstream() 也是「从集合造一个视图」。 方向是对的,但 Stream 只能用一次,而且和集合的 API 是两套。
  • Python 的迭代器协议:任何实现了 __iter__ 的东西 都能用 for、能被 map。这是最接近 seq 的设计—— 同样是「能不能」而不是「是不是」。
  • Unix 管道cat | grep | sort 之所以能任意拼, 是因为所有程序都同意一个最小契约:从标准输入按行读,往标准输出按行写。 seq 就是数据结构世界的这条契约。
✗ 这个直觉是错的

map 作用在向量上,返回的应该还是向量。」

map 返回的永远是序列,不是你传进去的类型。

(map inc [1 2 3])         ;; => (2 3 4)     圆括号!不是 [2 3 4]
(class (map inc [1 2 3])) ;; => clojure.lang.LazySeq

要拿回向量,得自己说:(mapv inc [1 2 3]) 或者 (into [] (map inc) [1 2 3])

为什么这么设计?因为 map 根本不知道也不关心你传的是什么—— 它只会问 first/rest。它不能返回向量, 因为它作用的对象可能是一个无限序列,而无限的东西装不进向量。

这个「不一致」其实是卷 III 的入场券:序列可以是无限的,集合不行。

◇ 另存一版

答案是 Crest()nextnilseqnil

A 「三个都是 nil」——那样的话每次链式调用前都要判空, rest 保证给序列正是为了免掉这件事。

B 「三个都是 ()」——那样就失去了「还有没有」这个信号, 你得每次都 (empty? ...) 一下。 Clojure 选择让 nil 直接承担「没有了」的语义。

D ——正好反了。记忆法: rest 是「剩下的」(一定是个序列),next 是「下一个还有吗」(没有就 nil)

你进来时:不同容器要用不同的遍历方式, 统一处理得靠共同的父接口。

你出去时:统一不靠继承,靠在使用点问一个问题; 任何能回答 first/rest 的东西都自动接入几百个函数。

卷 II 到这里,四章讲的其实是同一件事的四个面: 字面量(第 5 章)、不建类(第 6 章)、解构(第 7 章)、seq(第 8 章)—— 都是在降低「直接使用数据」的成本

这一章的一句话

seq 不是一个类,是一个问题:你能不能给我第一个,和剩下的。 能回答,就自动接入几百个函数。

卷 II 结束。卷 III 从上面那条错误直觉的最后一句进去: 序列可以是无限的。既然是无限的,它显然不能提前算好, 那它什么时候算?答案是「你要的时候」—— 但下一章之后紧接着的第 10 章会告诉你,这句话有一个很大的星号: 你要 1 个的时候,它经常算 32 个。