卷 VI · 造语CH 24深度 24/25

契约:把「我以为」写进代码

Racket 是动态类型的,这一直是它的一个「弱点」(第 11 章我诚实地承认过)。那它怎么保证正确性?答案是一个很多语言都没有、但概念极其漂亮的东西:契约(contract)。它比静态类型来得晚(运行时才检查),但比普通断言强得多 —— 强在一个词:blame(归责)。当一个契约被违反,它不只说「出错了」,它精确地告诉你是的错:是调用方传错了参数,还是实现方返回了错的结果。而这个「怪谁」的精确判定,会让你重新审视项目里的每一个 require 检查、每一个 API 边界。

契约blame前置条件后置条件防御性编程

从一个你天天写的东西开始

你写过无数这样的「防御性检查」:

// 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)字段级契约部分

几条能立刻用上的实践:

requirecheck 要分清 —— 它们就是 blame。这是 Kotlin 标准库一个被低估的设计:

  • require = 「怪调用者」(参数不对),抛 IllegalArgumentException
  • check = 「怪自己」(内部状态不对),抛 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 时随身带的行囊里。

一张对照表,十二件能立刻用的东西,一份自检清单,和一个问题的答案:学完这本书,接下来该读什么。