解构:一行顶十行
上一章欠了一笔账:什么都用 map,取值岂不是要写一堆嵌套的括号?这一章还账。解构是一套纯语法,不需要数据实现任何接口,也不生成任何类——它只是把「怎么拆」写在了绑定的位置上。
(let [{:keys [a] :or {a 9}} {:b 1}]
a)
这段代码里,数据 {:b 1} 根本没有 :a 这个键。
问:a 绑出来是什么?
它长什么样
解构出现在任何绑定的位置:let 的左边、函数的参数表、
loop、doseq 等等。规则只有一条:
把「你想要的形状」写在原本写变量名的地方。
;; 不解构
(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}]
...)
这一个参数表干了五件事:取出 id 和 items;
钻进 :address 里取出 city 和 zip;
给 city 配了默认值;还留了一份完整的 order。
对应的 Kotlin 大概是十行,而且每一层都要判空。
解构不是「模式匹配」,它不做分支:形状对不上时它给 nil,
不会跳到另一个分支去。它是一个纯粹的取值语法,不是控制流。
为什么这件事比看起来重要
上一章说,「用普通 map 不建类」的代价之一是取值麻烦。 解构把这个代价压到接近于零,而这一步之所以能成立, 是因为它满足三个条件:
- 它是语法,不是接口。数据不需要实现
Destructurable, 任何 map、任何向量、任何序列都能被解构。你从 API 拿到的原始 JSON 就能直接拆。 - 它不生成类型。不像 Kotlin 的
data class解构声明, 它不要求先有一个类,也不受componentN()顺序的约束。 - 它只在绑定处生效。不影响数据本身,不改变值,
也不产生额外的对象——展开之后就是几个
get调用。
三条合起来的效果是:「拆数据」这个动作变得比「定义数据的形状」还便宜。 于是不定义形状就成了一个理性选择,而不是懒。
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 按名字取,没有这个问题。
你每天都在用解构,只是在别的语言里它叫别的名字:
- JavaScript:
const {name, age = 18} = user—— 几乎是逐字对应的,连默认值都有。ES6 的这个设计明确受了 Clojure 影响。 - Python:
a, 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 产生的那个东西——为什么 map、filter、
first 对它们全都能用?它们没有共同的父类。
答案是一个只有两个方法的抽象,而它是整个卷 III 的地基。