没有类,只有 map
你的第一反应是给每种数据建一个类。Clojure 的第一反应是「先用 map」。这一章讲这个选择买到了什么、赔上了什么,以及一个会咬人的实现细节:第 9 个键。
(def m {:z 1 :y 2 :x 3})
(println m)
问:打印出来的键,顺序是什么?如果我再往里加六个键呢?
先看看不建类是什么样
一个订单。在 Kotlin 里你会这么写:
data class Address(val city: String, val zip: String)
data class Item(val sku: String, val qty: Int, val price: Int)
data class Order(
val id: String,
val customer: String,
val address: Address,
val items: List<Item>
)
在 Clojure 里,你直接写数据:
{:id "A-1001"
:customer "Ada"
:address {:city "Auckland" :zip "1010"}
:items [{:sku "X1" :qty 2 :price 1500}
{:sku "X2" :qty 1 :price 800}]}
没有类型声明,没有构造函数,没有 data class。
形状就写在那儿,你看见的就是全部。
操作它用的是普通函数:
(:customer order) ;; => "Ada" (get-in order [:address :city]) ;; => "Auckland" (assoc order :status :paid) ;; 加一个字段,返回新订单 (update-in order [:items 0 :qty] inc) ;; 深处改一个数,返回新订单 (dissoc order :address) ;; 去掉一个字段
update-in 那一行值得盯一会儿:
它在一个嵌套三层的结构里改了一个数字,返回一个新订单,
而原来那个订单一个字节都没变。
在 Kotlin 里做同一件事:
order.copy(items = order.items.mapIndexed { i, item ->
if (i == 0) item.copy(qty = item.qty + 1) else item
})
每深一层,你就要多写一层 copy。这是「不可变 + 静态类型 + 每层一个类」
三件事撞在一起的必然结果,Kotlin 已经算做得好的了。
这笔交易的两头
把话说清楚,不要只讲好处。
- 所有函数立刻可用。
assoc、merge、select-keys、update-in对任何 map 都成立, 不用为你的类再写一遍。 - 组合与拆分是免费的。合并两个订单?
(merge a b)。 只要三个字段?(select-keys order [:id :customer :status])。 在类的世界里这两件事各要一个新类型。 - API 数据不用映射。JSON 进来就是 map,直接用;
出去也一样。没有 DTO,没有
@JsonProperty。 - 加字段不破坏任何人。map 是开放的: 多一个键不算错,不认识它的代码自然会忽略它。
- 打错字不会被发现。
(:custmer order)返回nil, 不报错。这个 nil 会一路飘到很远的地方才炸。这是最实在的一笔代价。 - 形状不写在代码里。一个函数收到一个 map, 你怎么知道里面该有什么?靠文档、靠读实现、靠猜。
- IDE 帮不上忙。没有自动补全字段名,没有「重命名字段」的重构。
这三条不是小事。第 22 章的 spec 就是 Clojure 对它们的正式回答:
不把形状写进类型,而是写成一份可选的、运行期的、还能反过来生成数据的合同。
在读到那一章之前,请把这三条记在心里——这本书不打算假装它们不存在。
第 9 个键
现在揭开始那道题。答案是 D。
Clojure 的 map 有两套实现,它会在你背后自动切换:
键的个数 实现 顺序 查找 ────────────────────────────────────────────────────────────── ≤ 8 PersistentArrayMap 插入顺序 线性扫 ≥ 9 PersistentHashMap (HAMT) 哈希顺序 近乎常数
八个键以内,它就是一个扁平数组 [k1 v1 k2 v2 …],
挨个比过去。这么小的规模,线性扫比算哈希还快,
而且顺带保住了插入顺序。
第 9 个键进来,实现换成第 2 章那种 32 岔树(这次是按哈希分岔的版本,叫 HAMT)。 顺序当场按哈希重排。把上面那台 demo 的滑杆从 8 拨到 9,你会当场看见这一跳。
为什么这值得单独讲?因为它是一个会咬人的坑: 你写代码时 map 只有三五个键,看起来老老实实保着插入顺序, 于是你(可能是无意识地)依赖了这个顺序—— 比如直接把 map 打印进日志、比如按顺序生成 CSV 表头。 等数据长到 9 个键,一切照旧编译、照旧运行,只是顺序变了。
「map 是无序的」这句话经常被理解成「顺序随机」。 不是。它的准确意思是:顺序由实现决定,而实现可能变,所以你不该依赖它。 同一份数据在同一个版本里跑一百次,顺序是一样的—— 这恰恰是危险的地方,因为它让你误以为顺序是稳定的。
真要顺序,有两个明确的工具:sorted-map(按键排序)
或者干脆用向量存键值对。
那什么时候才该建类型
Clojure 也有 defrecord,它生成一个真的 JVM 类。什么时候用?
(defrecord Order [id customer])
(def o (->Order "A-1001" "Ada"))
(:customer o) ;; => "Ada" ← 用起来还是 map
(assoc o :status :paid) ;; ← 还是 map 的操作
(= o {:id "A-1001" :customer "Ada"}) ;; => false ← 但它不等于普通 map
记录是「带类型的 map」:所有 map 操作都能用,但它额外带着一个类型, 于是可以参与第 17 章的协议派发,字段访问也更快一点。
社区的经验法则很朴素:先用 map; 当你需要「按类型分派行为」的时候,再换成记录。 而这个时刻比你以为的晚得多——大部分数据从头到尾都不需要类型。
你其实已经在用「不建类」的方式写代码了,只是没意识到:
- 前端:从接口拿到 JSON,直接
data.user.name, 很少有人先定义一个 User 类。 - Python / Ruby 脚本:一个 dict 走天下。
- 日志和监控:结构化日志就是一坨 map, 你从来不会为每种日志事件定义一个类。
Clojure 的立场是:既然在这些场合「一坨数据」明明够用而且更灵活, 为什么在业务代码里就必须先建类型?它把这个问题反过来问了。
《当真》(PostgreSQL):那本书的主线是 「MySQL 什么都吞下去,PostgreSQL 不合规就当场拒绝」。 这一章正好站在另一头——map 是那个「什么都吞下去」的一方。 两本书合起来看,你会更清楚这个取舍的两端各自要付什么: 严格的一方把成本前置到写入时,宽松的一方把成本推迟到出错时。 第 22 章的 spec 是想两头都要一点的尝试。
答案是 D:8 个键以内是 PersistentArrayMap,
保插入顺序;第 9 个键进来换成 PersistentHashMap,顺序按哈希重排。
A 「永远保插入顺序」——这是最危险的一个答案, 因为它在你的小测试里看起来完全正确。这就是这类 bug 上线才出现的原因。
B 「按键排序」——那是 sorted-map 的行为,
普通 map 不排序。
C 「每次运行都不一样」——不会。同一份数据在同一版本里顺序是确定的。 它不随机,只是不由你决定。
你进来时:给数据建类是默认动作,map 是「临时凑合」用的。
你出去时:map 是默认动作,建类型是为了派发行为才做的事; 代价是打错字不报错、形状不在代码里——这笔账第 22 章来结。
前面五章一条没变。这一章只重建了一条路径: 「数据要先有形状」这个前提。
这一章的一句话
先用 map,需要按类型派发时再建类型。 买到的是所有函数立刻可用,赔上的是打错字不会有人告诉你。
下一章解决上面那笔账里最烦的一部分:
如果所有东西都是 map,那取值岂不是要写一堆 (:a (:b (:c m)))?
不用。Clojure 有一套叫解构的纯语法,一行能把嵌套结构里的七八个值同时绑出来,
还能带默认值——这正是「就用 map」能站住脚的另一半原因。