卷 II · 手上的活CH 07深度 7/24

解构:一行顶十行

上一章欠了一笔账:什么都用 map,取值岂不是要写一堆嵌套的括号?这一章还账。解构是一套纯语法,不需要数据实现任何接口,也不生成任何类——它只是把「怎么拆」写在了绑定的位置上。

向量解构map 解构默认值与 :as

▷ 先猜一下
(let [{:keys [a] :or {a 9}} {:b 1}]
  a)

这段代码里,数据 {:b 1} 根本没有 :a 这个键。

问:a 绑出来是什么?

A nil——没这个键就是 nil B 9——:or 提供了默认值 C 报错:找不到键 :a D 1——退而取了唯一存在的那个值

它长什么样

解构出现在任何绑定的位置let 的左边、函数的参数表、 loopdoseq 等等。规则只有一条: 把「你想要的形状」写在原本写变量名的地方

;; 不解构
(let [point [3 4]]
  (let [x (first point)
        y (second point)]
    (+ x y)))

;; 解构
(let [[x y] [3 4]]
  (+ x y))

左边写 [x y],意思是「右边那个东西是个序列, 把第一个绑给 x,第二个绑给 y」。map 也一样:

(let [{:keys [name age]} {:name "Ada" :age 36}]
  (str name " is " age))
;; => "Ada is 36"

:keys 是最常用的写法:用同名的键去取,绑到同名的变量上。 下面这台 demo 是真的解构引擎——两边都能改,它会把绑出来的每个名字列给你:

四个记号,够用九成

;; 1. :keys —— 取同名的键
(let [{:keys [a b]} {:a 1 :b 2}] [a b])          ;; => [1 2]

;; 2. :or —— 缺了就用默认值
(let [{:keys [a] :or {a 9}} {}] a)               ;; => 9

;; 3. :as —— 我还想要完整的那个东西
(let [{:keys [a] :as whole} {:a 1 :b 2}]
  [a whole])                                     ;; => [1 {:a 1, :b 2}]

;; 4. & —— 剩下的都给我
(let [[first & rest] [1 2 3 4]] [first rest])    ;; => [1 (2 3 4)]

这四个可以任意组合、任意嵌套:

(defn ship! [{:keys [id items]
              {:keys [city zip]} :address
              :or {city "未填"}
              :as order}]
  ...)

这一个参数表干了五件事:取出 iditems; 钻进 :address 里取出 cityzip; 给 city 配了默认值;还留了一份完整的 order。 对应的 Kotlin 大概是十行,而且每一层都要判空。

◆ 可以带走的判断

解构不是「模式匹配」,它不做分支:形状对不上时它给 nil, 不会跳到另一个分支去。它是一个纯粹的取值语法,不是控制流。

为什么这件事比看起来重要

上一章说,「用普通 map 不建类」的代价之一是取值麻烦。 解构把这个代价压到接近于零,而这一步之所以能成立, 是因为它满足三个条件:

  1. 它是语法,不是接口。数据不需要实现 Destructurable, 任何 map、任何向量、任何序列都能被解构。你从 API 拿到的原始 JSON 就能直接拆。
  2. 它不生成类型。不像 Kotlin 的 data class 解构声明, 它不要求先有一个类,也不受 componentN() 顺序的约束。
  3. 它只在绑定处生效。不影响数据本身,不改变值, 也不产生额外的对象——展开之后就是几个 get 调用。

三条合起来的效果是:「拆数据」这个动作变得比「定义数据的形状」还便宜。 于是不定义形状就成了一个理性选择,而不是懒。

☕ 写 Java 的你

Kotlin 有解构声明,但绑在 componentN() 上:

val (name, age) = person   // 按位置,不是按名字
// 换了字段顺序,这行会静默地绑错

Java 21 的 record pattern 更接近:

if (o instanceof Order(String id, Address(String city, _))) { ... }

方向是对的,但仍然要求对方是一个 record—— 你没法这样拆一个 Map<String, Object>。 Clojure 的解构不挑数据,因为它拆的就是 map 和序列本身。

另外注意 Kotlin 那行的坑:按位置绑定意味着 改了 data class 的字段顺序,所有解构点都会静默绑错:keys 按名字取,没有这个问题。

▸ 在现实里

你每天都在用解构,只是在别的语言里它叫别的名字:

  • JavaScriptconst {name, age = 18} = user —— 几乎是逐字对应的,连默认值都有。ES6 的这个设计明确受了 Clojure 影响。
  • Pythona, b, *rest = xs
  • HTTP 处理函数:这是解构最舒服的场合。 请求是一个 map,一个参数表就把 :params:headers:body 全拆出来了。
✗ 这个直觉是错的

:or 的意思是『键不存在时用默认值』, 所以只要键在,就一定用键的值。」

:or键不存在或者值是 nil 时都会生效。 这在 Clojure 里通常正是你要的(nil 就是「没有」), 但如果 nil 对你是一个有意义的值,就会被默认值悄悄顶掉:

(let [{:keys [x] :or {x 9}} {:x nil}]
  x)     ;; => 9,不是 nil

要区分「没有这个键」和「有但是 nil」,得老实用 contains?。 这条不常咬人,但咬起来很难查——因为你会盯着数据说「明明有这个键啊」。

◇ 另存一版

答案是 B,9:or 提供的默认值在键缺失时生效。

A 「没这个键就是 nil」——这是没有 :or 时的行为, 也是解构的默认脾气:形状对不上不报错,给 nil

C 「报错」——这是把解构当成了模式匹配。 模式匹配讲究「匹配失败走另一条路」,解构不做分支,它只管取。

D 「退而取唯一存在的值」——没有任何语言会这么干, 但这个选项想提醒你:解构完全按名字,不会「聪明地猜」。

你进来时:不建类型的代价是取值麻烦,得写一堆嵌套调用和判空。

你出去时:取值比定义类型还便宜——一个参数表能同时完成 取多个字段、钻进嵌套、配默认值、留一份完整原件。

上一章那三条代价(打错字不报错、形状不在代码里、IDE 帮不上忙) 一条都没有被这一章抵消——解构解决的只是「麻烦」,不是「不安全」。 那三条要等第 22 章。

这一章的一句话

解构是纯语法:不挑数据、不生成类型、只在绑定处生效。 它让「拆数据」比「定义数据的形状」还便宜。

下一章收掉卷 II 最后一个疑问。到这里你已经见过向量、列表、字典、集合、字符串、 还有 range 产生的那个东西——为什么 mapfilterfirst 对它们全都能用?它们没有共同的父类。 答案是一个只有两个方法的抽象,而它是整个卷 III 的地基。