卷 VI · 落地CH 22深度 22/24

spec:不是类型,是运行期的合同

第 6 章欠了一笔账:什么都用 map,打错一个字段名不会有人告诉你。这一章还账。Clojure 的回答不是加个类型系统,而是一份写成数据的合同——它能表达类型系统表达不了的东西,还能反过来生成数据。

conform / explain规格即数据生成与收缩

▷ 先猜一下

下面四件事,一个静态类型系统(Java / Kotlin 那种)能表达几件?

① 这个字段是字符串
② 这个字段是 0 到 130 之间的整数
③ 这个 map 必须有 :name 和 :age,可以有别的键
④ 用它自动生成一千个合法的测试数据
A 四件都能 B 能 ①③,②要靠运行期校验,④做不到 C 只能 ①,其余三件都超出了类型系统的范围 D 能 ①,②③④ 各需要一个额外的库

先看它长什么样

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 多倍是真的,但那是不是挑了个对自己有利的比法?