卷 II · 手上的活CH 06深度 6/24

没有类,只有 map

你的第一反应是给每种数据建一个类。Clojure 的第一反应是「先用 map」。这一章讲这个选择买到了什么、赔上了什么,以及一个会咬人的实现细节:第 9 个键。

数据优先嵌套更新array-map 阈值

▷ 先猜一下
(def m {:z 1 :y 2 :x 3})
(println m)

问:打印出来的键,顺序是什么?如果我再往里加六个键呢?

A 永远是 :z :y :x,map 保持插入顺序 B 永远是 :x :y :z,map 按键排序 C 顺序不确定,每次运行可能都不一样 D 小的时候是插入顺序,超过某个大小就不是了

先看看不建类是什么样

一个订单。在 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 已经算做得好的了。

这笔交易的两头

把话说清楚,不要只讲好处。

◆ 买到了什么
  • 所有函数立刻可用。assocmergeselect-keysupdate-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 的立场是:既然在这些场合「一坨数据」明明够用而且更灵活, 为什么在业务代码里就必须先建类型?它把这个问题反过来问了。

◇ 另存一版

答案是 D:8 个键以内是 PersistentArrayMap, 保插入顺序;第 9 个键进来换成 PersistentHashMap,顺序按哈希重排。

A 「永远保插入顺序」——这是最危险的一个答案, 因为它在你的小测试里看起来完全正确。这就是这类 bug 上线才出现的原因。

B 「按键排序」——那是 sorted-map 的行为, 普通 map 不排序。

C 「每次运行都不一样」——不会。同一份数据在同一版本里顺序是确定的。 它不随机,只是不由你决定。

你进来时:给数据建类是默认动作,map 是「临时凑合」用的。

你出去时:map 是默认动作,建类型是为了派发行为才做的事; 代价是打错字不报错、形状不在代码里——这笔账第 22 章来结。

前面五章一条没变。这一章只重建了一条路径: 「数据要先有形状」这个前提

这一章的一句话

先用 map,需要按类型派发时再建类型。 买到的是所有函数立刻可用,赔上的是打错字不会有人告诉你。

下一章解决上面那笔账里最烦的一部分: 如果所有东西都是 map,那取值岂不是要写一堆 (:a (:b (:c m)))? 不用。Clojure 有一套叫解构的纯语法,一行能把嵌套结构里的七八个值同时绑出来, 还能带默认值——这正是「就用 map」能站住脚的另一半原因。