卷 II · 手上的活CH 05深度 5/24

四种括号,四种脾气

向量、列表、字典、集合。这一章不背 API,只回答一个问题:为什么同一个 conj,往向量里加是加在末尾,往列表里加却是加在开头?答案会顺手把这四种结构的性格全部交代清楚。

四种字面量conj 的真正含义into 与转换

▷ 先猜一下
(conj [1 2 3] 9)     ;; 向量
(conj '(1 2 3) 9)    ;; 列表

问:这两行分别返回什么?

A 都是 (1 2 3 9)——conj 就是「加到末尾」 B [1 2 3 9] 和 (1 2 3 9)——都加在末尾,只是类型不同 C [1 2 3 9] 和 (9 1 2 3) D 第二行报错,列表是不可变的,不能 conj

如果你选了 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 的你

四种容器都是字面量,这件事的分量比看上去大。 在 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 个键时,它的实现会在你眼皮底下换掉, 顺序当场重排。