一个接口,跑遍所有容器
向量、列表、字典、集合、字符串、range、还有还没算出来的惰性序列——同一组函数对它们全部有效,而它们之间没有继承关系。这一章讲那个把它们串起来的东西,它只有两个方法。
(rest [1]) ;; A (next [1]) ;; B (seq []) ;; C
这三行分别返回什么?
这道题看起来是抠细节,其实问的是:「空」和「没有」是不是同一件事?
两个方法,全部家当
Clojure 里所有序列操作都建立在两个问题上:
first —— 给我第一个 rest —— 给我剩下的
就这两个。map、filter、reduce、take、
partition——几百个序列函数,全部可以只用这两个方法写出来。
所以一个东西要能被这几百个函数处理,需要满足的条件是: 它能回答「给我第一个,和剩下的」。就这么多。 不需要继承某个基类,不需要实现十个方法,不需要是集合。
看那张表:七种完全不同的东西,同一组函数全部有效。
而它们在 Java 世界里的对应物(List、Map、
String、IntStream)互相之间毫无关系,
你得为每种写不同的遍历代码。
关键在于它是「问」,不是「是」
这是这一章真正的点,值得慢一点讲。
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永远返回一个序列(可能是空的)。 方便你安心地链下去,不用判空。seq和next在没东西时返回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 的 Stream:
stream()也是「从集合造一个视图」。 方向是对的,但 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 的入场券:序列可以是无限的,集合不行。
答案是 C:rest 给 (),
next 给 nil,seq 给 nil。
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 个。