CLASS 04 · 2026-02-11 · 规格与设计
规格说明
规格说明是模块实现者与使用者之间的契约,是团队合作的支点。本讲学习前置条件、后置条件、异常的使用,以及如何写出清晰、无歧义的函数规格。
为什么需要规格
规格说明是团队合作的支点(linchpin)。它在模块的实现者与使用者之间充当契约。
精确的规格让你能够分摊责任(apportion blame):当程序出错时,规格告诉你错在调用者(违反了前置条件)还是实现者(未满足后置条件)。没有规格,调试就变成了无头苍蝇。
行为等价性
判断两个实现是否行为等价(behavioral equivalence),就看能否在不影响正确性的前提下互相替换。规格正是判断等价性的依据——满足同一规格的所有实现,从使用者角度看都是等价的。
规格的结构
一个完整的函数规格包含三部分:
- 函数签名:函数名、参数类型、返回类型。
- 前置条件(requires clause):调用者必须满足的义务。
- 后置条件(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);
}
异常
异常分为两类:
- 表示 bug 的异常:例如前置条件被违反,通常不捕获,让程序快速失败。
- 可预见的失败:例如文件不存在、网络超时,应在规格中用
@throws文档化。
特殊返回值
用联合类型表达特殊情形的结果,而不是返回 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)。