CLASS 05 · 2026-02-18 · 规格与设计
设计规格说明
不是所有规格都有用。本讲学习如何设计出清晰、恰当的规格:区分声明式与操作式、理解规格的强弱、掌握前置与后置条件的工程取舍,并认识可变性带来的风险。
确定规格 vs. 欠确定规格
如果一个规格对同一输入允许不同的结果,则称为欠确定(underdetermined)。完全确定的规格每次调用都唯一决定结果;欠确定规格则给实现者留出了自由度(例如排序算法可以返回任意一种稳定排序结果)。
声明式规格 vs. 操作式规格
- 操作式(Operational):描述「怎么做」——一步步的流程。
- 声明式(Declarative):描述「做什么」——只给出最终结果的性质。
声明式规格几乎总是更可取的。
声明式规格更简洁、更易于理解,且给实现者更大的优化空间。
// 操作式(不好):描述步骤
// "遍历数组,把每个元素逐个添加到一个新数组中..."
// 声明式(好):描述结果
/**
* 返回数组的一个副本,其中元素按升序排列。
* @param arr 输入数组
* @returns 新数组,包含 arr 的所有元素且按升序排列
*/
function sort(arr: number[]): number[];
更强 vs. 更弱的规格
S2 比 S1 更强(stronger),当且仅当满足 S2 的实现集合是满足 S1 的实现集合的真子集。等价地说:S2 的前置条件弱于或等于 S1,且 S2 的后置条件强于或等于 S1。
- 更弱的前置条件:对调用者更友好(更多输入被接受)。
- 更强的后置条件:对调用者更有用(更多关于结果的保证)。
好的规格应该对调用者足够强(提供有用的保证),同时对实现者足够弱(留出实现自由度)。
// 弱规格:只要求返回数组中的某个元素
// @returns arr 中的某个元素
// 中等规格:返回最大值
// @returns arr 中的最大元素
// 强规格:返回最大值及其索引
// @returns { value: arr 中的最大元素, index: 其索引 }
用图表示规格
把「所有可能的实现」看作一个空间,一个规格就定义了这个空间中的一个区域——区域内所有实现都满足该规格。更强的规格对应更小的区域。
可变性的风险
可变性(mutability)是许多 bug 的根源。两个典型风险:
- 传递可变值:把可变对象传给函数,函数可能在内部修改它,违反调用者预期。
- 返回可变值:返回内部可变对象的引用,调用者可能修改内部状态。
别名是可变类型风险的根源
当多个引用(别名,alias)指向同一个可变对象时,任何一个引用都可能修改该对象,而其他引用浑然不觉。这就是可变类型危险的本质。
TypeScript 中的只读集合
TypeScript 提供了只读类型来防御修改:ReadonlyArray、ReadonlySet、ReadonlyMap。它们省略了所有修改操作,让编译器替你守住不变性。
// 返回只读视图,防止调用者修改内部状态
function getItems(): ReadonlyArray<string> {
return ["a", "b", "c"];
}
const items = getItems();
// items.push("d"); // Error: Property 'push' does not exist on type 'readonly string[]'.
设计好规格的原则
- 连贯(Coherent):规格逻辑自洽,不自相矛盾。
- 慎重考虑可变性:明确哪些参数可被修改。
- 足够强:对调用者提供真正有用的保证。
- 足够弱:给实现者留出优化和变更的空间。
前置条件还是后置条件
是否使用前置条件是一个工程判断。前置条件把验证负担推给调用者(更弱的前置 = 更友好的 API);后置条件把验证负担留给实现者(更强的后置 = 更多的检查)。选择取决于错误的代价和验证的成本。
规格用在哪里
规格不仅用于公开 API,也用于辅助函数、导出函数、库函数和语言标准。任何需要他人(或未来的自己)理解的代码,都值得配上规格。
核心要点
声明式描述「做什么」而非「怎么做」;声明式规格更优。
强弱弱前置(对调用者友好)+ 强后置(对调用者有用)= 好规格。
可变性别名 + 可变对象 = bug;用只读类型防御。