spec:不是类型,是运行期的合同
第 6 章欠了一笔账:什么都用 map,打错一个字段名不会有人告诉你。这一章还账。Clojure 的回答不是加个类型系统,而是一份写成数据的合同——它能表达类型系统表达不了的东西,还能反过来生成数据。
下面四件事,一个静态类型系统(Java / Kotlin 那种)能表达几件?
① 这个字段是字符串 ② 这个字段是 0 到 130 之间的整数 ③ 这个 map 必须有 :name 和 :age,可以有别的键 ④ 用它自动生成一千个合法的测试数据
先看它长什么样
spec 就是把「数据该是什么样」写成数据:
(require '[clojure.spec.alpha :as s]) (s/def :user/name string?) (s/def :user/age (s/int-in 0 130)) (s/def :user/person (s/keys :req [:user/name :user/age]))
然后拿它去问:
注意 explain 的输出:它不只说「不合法」,
它说清了哪个值、在哪个位置、违反了哪一条。
这一点比大多数校验库都强,而且是免费的——
因为规格本身是结构化的数据,错误信息可以从结构里推出来。
它和类型系统差在哪
开头那道题的答案是 B。三个关键差别:
一、它在运行期,所以能表达「值」而不只是「形状」。
(s/def :user/age (s/int-in 0 130)) ;; 范围
(s/def ::password #(>= (count %) 8)) ;; 长度
(s/def ::order
(s/and (s/keys :req [::items ::total])
#(= (::total %) (reduce + (map ::price (::items %))))))
;; ▲ 总额必须等于各项之和 —— 一个跨字段的约束
最后那条是关键。类型系统里,「总额等于各项之和」这种东西 要么表达不了,要么需要非常重的类型体系(依赖类型)。 而在运行期它只是一个普通的谓词函数。
二、它是可选的,可以只用在边界上。
你不需要给每个函数、每个数据都写 spec。 通常的做法是只在系统的入口处校验: HTTP 请求进来、消息从队列取出来、配置文件读进来。 内部函数之间不检查——因为数据已经在边界上被验过了。
三、它能反着跑。这是它最独特的地方,值得单独一节。
类型系统的价值在编译期、全覆盖、零成本; spec 的价值在能表达任意约束、可选、能生成数据。
它们不是同一件事的两种做法,是两件不同的事。 真正的类型系统(Kotlin、Rust)能给你的那份保证,spec 给不了; 反过来也一样。
反着跑:从规格生成数据
一份 spec 写下来,你立刻免费得到一个生成器:
(gen/sample (s/gen :user/person) 3)
;; => ({:user/name "aQ" :user/age 47}
;; {:user/name "" :user/age 118}
;; {:user/name "xK9" :user/age 3})
为什么可能?因为 spec 是数据。(s/int-in 0 130)
不只是一个「检查函数」,它是一个知道自己在描述什么的结构,
所以能反过来构造符合它的值。
有了生成器,就有了属性测试: 不写「输入 3 应该输出 9」,而是写「对任何合法输入,某个性质都成立」, 让机器生成一千个例子去撞:
;; 性质:任何订单,打折之后总额不会变大
(defspec discount-never-increases 1000
(prop/for-all [order (s/gen ::order)]
(<= (:total (apply-discount order)) (:total order))))
找到反例之后,它还会收缩:
把那个乱七八糟的随机反例一步步变小,直到不能再小。
上面那台 demo 里演示了这个过程——
一个七元素的随机向量,收缩 7 次之后变成 [0 0 0 0 0]:
五个元素、全是 0,一眼就能看出规律是「长度 ≥ 5 就挂」。
还有一件顺手的事:conform 是个解析器
s/conform 不只是「验一下」,它会返回一个标注过的结构:
(s/def ::config
(s/cat :name string?
:opts (s/* keyword?)
:port int?))
(s/conform ::config ["server" :debug :verbose 8080])
;; => {:name "server", :opts [:debug :verbose], :port 8080}
;; ▲ 一个扁平的序列,被解析成了带名字的结构
这让 spec 可以用来解析参数列表——
Clojure 自己就用它来定义 defn 的参数语法。
写宏的时候这特别有用:与其自己拆 & body,
不如写一个 spec 让它帮你拆好,顺带在参数写错时给出人话的错误信息。
老实说:spec 的现状
这本书答应过不吹,所以要说清楚:
- 它还是 alpha。叫了很多年
clojure.spec.alpha,至今没转正。 - spec 2 停摆了。2019 年开始设计的下一版本长期没有进展, 社区里对此有真实的不满。
- 用它做函数参数校验(
s/fdef+ instrument)性能开销明显, 通常只在开发和测试期开。 - 社区有替代品:Malli 更活跃、更快、 规格本身就是普通数据(不用宏),很多新项目直接用它。
这些不影响这一章要讲的那个思路—— 「把规格写成数据,于是它能校验、能解释、能生成」—— Malli 用的是同一个思路,而且做得更彻底。
- JSON Schema:同一个思路,你可能已经在用。 它也是「规格是数据」,也能校验、能生成文档、能生成 mock 数据。
- OpenAPI:接口规格写成数据,于是能生成客户端代码、 生成文档、生成测试。这就是 spec 的价值主张,只是在 HTTP 层面。
- 属性测试:QuickCheck(Haskell)、 Hypothesis(Python)、jqwik(Java)。 书架上的《证伪》和《上游》都讲过收缩这件事。
共同点是:一份声明,多种用途。 而这件事的前提是这份声明必须是数据—— 如果它是编译期的类型注解,你就没法在运行期拿它去生成东西。
「spec 就是动态语言版的类型系统, 用它就能补上没有静态类型的短板。」
补不上,而且方向不同。
- 类型系统全覆盖:每个表达式都被检查。 spec 只覆盖你写了 spec 并且真的去调用校验的地方。
- 类型系统在编译期:错误在你运行之前就出现。 spec 在运行期:错误在数据流到那里时才出现。
- 类型系统零运行时成本;spec 的校验要真的跑。
反过来,类型系统也做不到 spec 那三件事: 表达任意值约束、按需可选、反向生成数据。
老实的结论是:如果你要的是「编译期全覆盖的保证」, 那就该用 Kotlin 或 Rust,而不是 Clojure + spec。 这是一个真实的取舍,第 24 章还会再提一次。
答案是 B:类型系统能表达 ①③, ② 要靠运行期校验,④ 做不到(除非额外引入属性测试库, 而且它没法从类型自动推出生成器)。
A 「四件都能」——② 需要依赖类型(Idris、Agda 那种);
④ 有些语言能部分做到(Haskell 的 Arbitrary),
但要为每个类型手写实例。
C 「只能 ①」——低估了。③ 那种「必须有这些字段, 可以有别的」正是类型系统的强项(结构化类型、可选字段)。
D 「各需要一个额外的库」——方向对了, 而这正是 spec 的卖点:一份声明同时干这四件事, 不用四个互不相通的工具。
你进来时(第 6 章):不建类型很爽,但打错字没人管,这笔账迟早要还。
你出去时:这笔账可以用一份写成数据的合同来还—— 代价是它在运行期、要你自己决定在哪儿校验; 回报是它能表达类型表达不了的约束,还能反过来生成数据。
第 6 章那三条代价只被抵消了两条: 打错字(校验能抓)、形状不在代码里(spec 就是形状)。 第三条「IDE 帮不上忙」依然成立—— spec 是运行期的,编辑器还是不能给你自动补全字段名。 这本书不打算假装这一条被解决了。
这一章的一句话
把规格写成数据,于是同一份声明可以校验、可以解释、可以生成数据。 代价是它在运行期,而且要你自己决定在哪里用。
下一章结账。前面二十二章一直在说不可变有多好, 现在把账单摊开:哪些地方它真的更慢、慢多少、 什么时候该用瞬变(transient)这个逃生舱。 顺带回答那个从第 2 章起就悬着的问题—— 比复制快 600 多倍是真的,但那是不是挑了个对自己有利的比法?