契约:把「我以为」写进代码
Racket 是动态类型的,这一直是它的一个「弱点」(第 11 章我诚实地承认过)。那它怎么保证正确性?答案是一个很多语言都没有、但概念极其漂亮的东西:契约(contract)。它比静态类型来得晚(运行时才检查),但比普通断言强得多 —— 强在一个词:blame(归责)。当一个契约被违反,它不只说「出错了」,它精确地告诉你是谁的错:是调用方传错了参数,还是实现方返回了错的结果。而这个「怪谁」的精确判定,会让你重新审视项目里的每一个 require 检查、每一个 API 边界。
从一个你天天写的东西开始
你写过无数这样的「防御性检查」:
// Kotlin
fun safeDiv(a: Int, b: Int): Int {
require(b != 0) { "除数不能为 0" } // 前置条件
return a / b
}
这就是契约的雏形。require 在说:「我对调用者要求:b 不能是 0。」如果调用者违反了,当场报错。
Racket 的契约系统把这个想法系统化、显式化了:
(require racket/contract) (define/contract (safe-div a b) (-> number? (and/c number? (not/c zero?)) number?) ; └─a的─┘ └────── b 的契约 ──────┘ └结果的┘ (/ a b)) (safe-div 10 2) ; ⇒ 5 (safe-div 10 0) ; ✗ 契约违反
那一行 (-> number? (and/c number? (not/c zero?)) number?) 读作:「接受一个 number,和一个『是 number 而且不是 0』的东西,返回一个 number。」它同时约束了两个参数和返回值。
契约就是把 API 文档里那些「参数必须非空」「返回值保证有序」的自然语言承诺,写成可执行的代码,并在运行时强制检查。
它站在类型和断言之间:
| 静态类型 | 契约 | 普通断言 | |
|---|---|---|---|
| 何时检查 | 编译期 | 运行时,在边界上 | 运行时,随处 |
| 能表达 | 结构(类型) | 任意谓词(非空、有序、在范围内、是素数……) | 任意谓词 |
| 归责(blame) | — | ✓ 精确到「谁的错」 | ✗ 只说「炸了」 |
| 开销 | 零(运行时) | 有(每次调用都查) | 有 |
「能表达任意谓词」是契约相对类型的优势(类型系统很难表达「这个 int 必须是正的」「这个 list 必须有序」),「有 blame」是契约相对断言的优势。后者是这一章的核心。
blame:契约真正的魔法
普通断言失败时,只告诉你「这里炸了」。契约失败时,它告诉你是谁违约了 —— 而这在调试时价值千金。
看两种违约。第一种:调用者传错了参数。
> (safe-div 10 0) safe-div: contract violation expected: (not/c zero?) given: 0 blaming: the caller ← 「怪调用者」
第二种,更精妙:实现者自己违反了自己的承诺。一个函数声称返回非负数,但实现有 bug:
(define/contract (buggy-sqrt x) (-> (>=/c 0) (>=/c 0)) ; 承诺:输入非负 → 输出非负 (- (sqrt x))) ; bug!返回了负数
> (buggy-sqrt 4) buggy-sqrt: broke its own contract ← 「它违反了自己的契约」 promised: (>=/c 0) produced: -2 blaming: (function buggy-sqrt) ← 明确指向实现,不是调用者 (assuming the contract is correct)
调试时最耗时间的问题往往不是「哪里错了」,而是「这到底是谁的错」:
- 是我调用你的方式不对(我传了非法参数)?
- 还是你的实现不对(你没兑现你文档里的承诺)?
没有 blame,你会对着一个深层的 NPE 或者 IllegalStateException,在两个模块之间来回猜,可能猜错方向、去改错地方。
契约在边界上就把这件事判定了。参数进来时不合格 → 怪调用者,错误停在边界外侧;返回值出去时不合格 → 怪实现,错误停在边界内侧。责任方,写在错误信息的第一行。
这在模块边界上尤其值钱:当 A 团队的代码调用 B 团队的库出了错,blame 直接结束了「这是谁的 bug」的扯皮 —— 它是一个不会撒谎的第三方裁判。
契约能表达类型表达不了的东西
契约是任意谓词,所以它能约束「值的性质」,而不只是「值的结构」:
(-> (and/c integer? positive?) ...) ; 必须是正整数 (-> (listof number?) (and/c list? sorted?)) ; 输入数字列表,保证返回有序 (-> string? (between/c 0 100)) ; 返回值在 0 到 100 之间 (-> (vectorof any/c) exact-nonnegative-integer? any/c) ; 索引必须合法 ; 甚至能表达参数之间的关系: (->i ([lo real?] [hi (lo) (>=/c lo)]) ...) ; hi 必须 >= lo!
最后那个尤其惊人:它约束了两个参数之间的关系(hi >= lo)。这是绝大多数静态类型系统(包括 Kotlin、TypeScript)做不到的 —— 需要「依赖类型」(dependent types)才行,而那是研究级语言(Idris、Agda)的东西。契约用运行时检查,轻松做到了。
类型:编译期,零开销,但只能表达结构,且「能表达的」有上限。
契约:运行时,有开销,但能表达任意性质,包括参数间关系。
最好的实践往往是两者结合:用类型抓住「结构错误」(编译期,便宜),用契约抓住「性质错误」(运行时,在关键边界上)。typed/racket(第 23 章)甚至能让两者互相转化 —— 类型化模块和非类型化模块交界处,类型会自动变成契约来保护边界。这是「渐进类型」(gradual typing)的核心思想,而契约是它的黏合剂。
回流:你项目里到处都是「隐式契约」
你的代码里已经到处是契约了,只是它们藏在注释、文档、和你脑子里 —— 也就是最容易失效的地方。认出它们,把它们显式化:
| 你的语言里的契约 | 它检查什么 | 有 blame 吗 |
|---|---|---|
Kotlin require(...) | 前置条件(参数) → 抛 IllegalArgumentException | 半个 —— 语义上「怪调用者」,但不显式说 |
Kotlin check(...) | 状态/后置条件 → 抛 IllegalStateException | 半个 —— 语义上「怪自己」 |
Java Objects.requireNonNull | 非空前置条件 | 否 |
Guava Preconditions | 各种前置条件 | 否 |
TS 的类型守卫 / zod | 运行时校验外部数据(API 响应、表单) | zod 给出详细路径,接近 blame |
JVM 上的 Bean Validation(@NotNull @Min) | 字段级契约 | 部分 |
几条能立刻用上的实践:
① require 和 check 要分清 —— 它们就是 blame。这是 Kotlin 标准库一个被低估的设计:
require= 「怪调用者」(参数不对),抛IllegalArgumentExceptioncheck= 「怪自己」(内部状态不对),抛IllegalStateException
选对哪一个,你就是在向未来的调试者传递 blame 信息:异常类型本身告诉他「往边界的哪一侧看」。随手全用 require 或者全用 throw,就浪费了这个信号。(而且别忘了第 7 章:它们的第二个参数是 lambda,消息不会被白算。)
② 在模块 / 库 / 服务的边界上,把契约做厚。契约有运行时开销,所以不是每个函数都值得。但在信任边界上 —— 公共 API、模块的对外接口、微服务的入口、解析外部数据 —— 它的收益远超开销:它把「错误」挡在你的代码之外,并且明确判定责任方。zod(TS)在 API 边界校验数据之所以流行,正是这个道理 —— 它就是「解析层的契约」。
③ 用契约的思维读「防御性代码」。下次你在函数中间写一个 if (x == null) throw ...,问自己:这是一个前置条件(该在入口检查,怪调用者)还是一个不变量(该在状态变更处检查,怪自己)?把检查移到正确的边界上,并选对异常类型。你的错误信息会从「NPE at line 402」变成「你不该用 null 调用我」—— 后者能让排查时间少一个数量级。
④ 最深的一条:契约是「文档 + 测试」的合体,而且不会过期。API 文档会撒谎(代码改了文档没改),但契约不会 —— 它是可执行的,一旦承诺和实现不符,它当场 blame 实现方(就像上面那个 buggy-sqrt)。把关键的承诺写成契约,等于写了一份永远诚实的文档。
下一章
这是最后一章正文了。
整本书,我们从 Racket 出发,走了很远 —— 求值、闭包、续延、宏、造语言。下一章,我们全部收回来:把这一路捡到的东西,一件件擦亮,放进你回 Kotlin 和 JS 时随身带的行囊里。
一张对照表,十二件能立刻用的东西,一份自检清单,和一个问题的答案:学完这本书,接下来该读什么。