四种括号,四种脾气
向量、列表、字典、集合。这一章不背 API,只回答一个问题:为什么同一个 conj,往向量里加是加在末尾,往列表里加却是加在开头?答案会顺手把这四种结构的性格全部交代清楚。
(conj [1 2 3] 9) ;; 向量 (conj '(1 2 3) 9) ;; 列表
问:这两行分别返回什么?
如果你选了 C,接着回答:这是设计上的不一致吗?
四种容器,一张表
先把脸认全。这四种写法你在第 4 章见过,现在给它们配上脾气:
写法 名字 取第 n 个 加元素 查「在不在」 典型用途
────────────────────────────────────────────────────────────────────────
[1 2 3] 向量 快 O(log₃₂) 尾部快 慢 默认就用它
'(1 2 3) 列表 慢 O(n) 头部快 慢 代码、栈
{:a 1} 字典 — 快 键:快 结构化数据
#{1 2} 集合 — 快 元素:快 去重、成员判定
实际写代码时,九成的场合用向量。 列表主要出现在两个地方:代码本身(第 4 章说过,代码就是列表), 以及你需要一个栈的时候。
conj 的真正含义
答案是 C:[1 2 3 9] 和 (9 1 2 3)。
而这不是不一致,是同一条规则:
conj 的意思不是「加到末尾」,是
「加到这个结构最便宜的那一端」。
它承诺的是常数时间,不是位置。
为什么便宜的那一端不一样?回到结构:
向量是第 2 章那棵 32 岔树,末尾挂着一条尾巴。 往尾巴上加一个元素,多数情况下只复制那条最多 32 格的小数组—— 第 2 章量过,尾巴没满时新建 0 个树节点。 而往开头加一个元素,意味着后面每个元素的下标都要挪一位, 整棵树都得重建。
列表是单向链表:每个节点存一个值和「下一个是谁」。 往开头加一个元素,就是新建一个节点指向原来的表头—— 旧的整条链原封不动地被共用,代价是 1 个节点。 而往末尾加,你得走到链尾,还得重建沿途每一个节点。
# 列表 conj:新建一个节点,剩下的整条链共用
旧表 ◇──▶ 1 ──▶ 2 ──▶ 3 ──▶ nil
▲
新表 ◆──▶ 9 ┘ ← 新建的只有这一个节点
1 2 3 三个节点两边共用
看出来了吗?这又是第 2 章那件事:结构共享。 列表的「便宜端」在头,是因为只有从头加才能让旧链整条被共用。
所以,与其记「向量加尾巴、列表加头」,不如记: 每种结构都有一端是可以共用旧结构的,conj 就加在那一端。
顺手:peek 和 pop 也跟着走
同一条规则贯穿到底:
(peek [1 2 3]) ;; => 3 向量看尾巴 (pop [1 2 3]) ;; => [1 2] 向量去尾巴 (peek '(1 2 3)) ;; => 1 列表看头 (pop '(1 2 3)) ;; => (2 3) 列表去头
于是你免费得到两种栈:想要后进先出就用向量, 想要一个便宜的「加在前面」就用列表。API 一样,行为跟着结构走。
而 first / rest 则永远从头看——
因为它们不是「结构操作」,是序列操作,
对任何容器都一个意思。这个区别是第 8 章的主题。
把一坨数据搬进另一种容器
into 是这卷里你会用得最多的函数之一。它就是「反复 conj」:
(into [] '(1 2 3)) ;; => [1 2 3] 列表 → 向量
(into #{} [1 1 2]) ;; => #{1 2} 去重
(into {} [[:a 1] [:b 2]]) ;; => {:a 1, :b 2} 键值对 → 字典
(into '() [1 2 3]) ;; => (3 2 1) ← 注意这个
最后一行值得停一下:搬进列表之后顺序反了。
为什么?因为 into 是反复 conj,
而列表的 conj 加在头上——先加 1,再把 2 加到 1 前面,
再把 3 加到最前面。这不是 bug,是上面那条规则的必然结果。
看到它反了,说明你真的懂了 conj。
四种容器都是字面量,这件事的分量比看上去大。 在 Java 里造一个嵌套结构:
Map<String, Object> m = new HashMap<>();
m.put("name", "Ada");
List<Integer> scores = new ArrayList<>();
scores.add(90); scores.add(85);
m.put("scores", scores);
在这里:
{:name "Ada" :scores [90 85]}
不只是短。关键在于它是一个表达式, 可以直接写在参数位置、返回值位置、测试的期望值位置, 不需要先造一个变量再一步步填。你在写测试时会立刻感受到这个差别。
你的 JSON 其实就是这四样东西里的三样:对象(字典)、数组(向量)、标量。 Clojure 的字面量和 JSON 几乎一一对应,多出来的只有集合和关键字。
这就是为什么 Clojure 处理 API 数据特别顺手: JSON 解析出来不需要映射成类,它本来就是你要用的形状。 第 6 章会把这件事讲透——「不建类」不是偷懒,是一个有代价也有回报的选择。
「'(1 2 3) 和 [1 2 3] 差不多,
选哪个看心情。」
它们的行为差很多:取第 n 个,向量是近乎常数时间,
列表要一路走过去;conj 加的位置也相反。
默认用向量,除非你明确需要「便宜的头部插入」。
还有一个更实际的坑:'(1 2 3) 里的引号会让整个结构不求值。
(let [x 5] '(1 2 x)) ;; => (1 2 x) ← x 是符号,不是 5 (let [x 5] [1 2 x]) ;; => [1 2 5] ← 向量正常求值 (let [x 5] (list 1 2 x)) ;; => (1 2 5) ← 要一个求了值的列表用 list
这是初学者最常见的一个困惑:明明写了个列表,里面的变量却没生效。 原因是你用的引号本意是「把这坨当数据」,而它确实照做了。
答案是 C:[1 2 3 9] 和 (9 1 2 3)。
不是不一致——conj 承诺的是「常数时间」,不是「末尾」,
而每种结构的常数时间端不一样。
A 「都是 (1 2 3 9)」——错两处:向量 conj 出来还是向量(类型不会变), 列表也不是加在末尾。
B 「都加末尾,只是类型不同」——这是最接近也最值得纠正的一个。 它假设 API 应该保证「位置一致」,但 Clojure 选择保证「复杂度一致」。 两者只能选一个,而选后者才能让结构共享一直成立。
D 「列表不可变,不能 conj」——不可变不等于不能操作, 只是操作会返回新值。这一条其实是第 1 章那个直觉的残留。
你进来时:conj 是「往集合里加一个」,位置大概是末尾。
你出去时:conj 加在能共用旧结构的那一端,
所以它的位置由结构决定;顺带地,into '() 会反序也就不再奇怪了。
第 2 章的结构共享一个字都没变,这一章只是发现 它决定了 API 的形状——连 conj 加在哪头这种小事都是它推出来的。
这一章的一句话
conj 加在这个结构最便宜的那一端,而「最便宜」的意思是「能共用旧结构」。
下一章处理一个更大的问题:既然有了这么好用的字面量, 那还要不要为每种数据定义一个类?Clojure 的答案是「先别」—— 而这个答案有一个具体的代价,我们会用一个滑杆把它展示出来: 当你的 map 长到第 9 个键时,它的实现会在你眼皮底下换掉, 顺序当场重排。