CLASS 04 · 2026-02-11 · 规格与设计

规格说明

规格说明是模块实现者与使用者之间的契约,是团队合作的支点。本讲学习前置条件、后置条件、异常的使用,以及如何写出清晰、无歧义的函数规格。

为什么需要规格

规格说明是团队合作的支点(linchpin)。它在模块的实现者与使用者之间充当契约。

精确的规格让你能够分摊责任(apportion blame):当程序出错时,规格告诉你错在调用者(违反了前置条件)还是实现者(未满足后置条件)。没有规格,调试就变成了无头苍蝇。

行为等价性

判断两个实现是否行为等价(behavioral equivalence),就看能否在不影响正确性的前提下互相替换。规格正是判断等价性的依据——满足同一规格的所有实现,从使用者角度看都是等价的。

规格的结构

一个完整的函数规格包含三部分:

  1. 函数签名:函数名、参数类型、返回类型。
  2. 前置条件(requires clause):调用者必须满足的义务。
  3. 后置条件(effects clause):实现者必须保证的结果。

用一句话概括:当前置条件满足时,实现者必须使后置条件成立。前置条件是调用者的责任,后置条件是实现者的责任。

/**
 * 在已排序数组中查找目标值的索引。
 * @param arr 按升序排列的数组(前置条件:arr 已排序)
 * @param target 要查找的目标值
 * @returns 目标值的索引;若不存在则返回 -1(后置条件)
 */
function binarySearch(arr: number[], target: number): number {
  // 实现...
}

规格中可以谈论什么

规格只能谈论参数返回值,绝不能提及局部变量或内部实现细节。这是保持「实现者与使用者解耦」的关键。

避免 null

null 值是麻烦与不安全的源头。Tony Hoare 自称引入 null 是「十亿美元的错误」。好的规格应避免让参数或返回值为 null,必要时用联合类型表达「可能缺失」的语义。

// 不好:返回值可能是 null
function findUser(id: number): User | null { /* ... */ }

// 更好:用异常或 Option 类型表达缺失,并在规格中明确说明
function findUser(id: number): User {
  // @throws Error 当用户不存在时
}

包含空值情形

空值(空数组、空字符串等)总是被允许的,除非规格明确禁止。写规格时要主动考虑空输入的行为。

测试与规格

即便是白盒测试也必须遵循规格。测试用例可以针对实现细节设计覆盖,但断言的内容必须是规格所承诺的行为。

修改函数的规格

程序员默认修改是不允许的,除非规格明确声明函数会修改参数。

如果函数会修改参数,必须在后置条件中写明 @modifies。这让调用者知道哪些值会被改变。

/**
 * 将元素添加到数组末尾。
 * @param arr 要修改的数组(@modifies arr)
 * @param elem 要添加的元素
 * @returns 无(后置条件:arr 比调用前多一个元素 elem)
 */
function push<T>(arr: T[], elem: T): void {
  arr.push(elem);
}

异常

异常分为两类:

特殊返回值

用联合类型表达特殊情形的结果,而不是返回 null 或魔术数字:

type Result<T> = { ok: true; value: T } | { ok: false; error: string };

function parseNumber(s: string): Result<number> {
  const n = Number(s);
  if (Number.isNaN(n)) return { ok: false, error: "not a number" };
  return { ok: true, value: n };
}

模块

在现代 TypeScript/JavaScript 中,每个文件就是一个模块。规格是模块对外暴露的契约,模块内部实现可以随时重构,只要规格不变,使用者就不受影响。

核心要点

契约规格 = 签名 + 前置条件 + 后置条件;调用者满足前置,实现者保证后置。

解耦规格只谈参数和返回值,不谈内部实现。

异常区分 bug(快速失败)和可预见失败(文档化 @throws)。