Reading 7: 设计规格(Designing Specifications)
Reading 7: 设计规格(Designing Specifications)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
上一讲(Reading 6: Specifications)讲清了「规格是什么」:前置条件(precondition)是客户必须满足的义务,后置条件(postcondition)是实现者必须兑现的承诺,只要双方都守约,实现者就可以自由替换实现。本讲要回答的是更深一层的问题:当我们可以为同一段行为写出多份不同的规格时,哪一份更好? MIT 6.031 给出了三个比较维度——确定性(deterministic 还是欠定 underdetermined)、声明性(declarative 还是操作式 operational)、强度(strong 还是 weak),并进一步讨论「什么样的规格是连贯、有用、松紧得当的」。
这三个维度直接服务于课程的三大目标:欠定与合适强度的规格让实现者能在规格内部自由更换算法,从而 Ready for change;清晰、声明式、连贯的规格让客户不必读实现代码就能理解用法,从而 Easy to understand;而明确的前置/后置条件和「快速失败(fail fast)」的取舍,让误解与非法调用尽早暴露,从而 Safe from bugs。一句话总结本讲的核心命题:写函数主要就是写规格,而规格的质量决定模块的可用性与可演化性。
核心概念与设计原则详解
规格即防火墙(Specification as a Firewall)
- 定义与目的:规格是客户与实现者之间的一道屏障:客户无需阅读源码即可调用模块,实现者无需知道调用现场即可编写实现。它同时保护「安全性」(双方不越界)、「易理解性」(客户只读契约)与「可修改性」(实现可替换)。
- 直观解释(”它是什么?”):把规格想象成一块围起来的场地。实现者可以在场地内部任意走动——换算法、优化性能、重写代码——只要不出界,客户就不会受影响;客户则站在场地外,只能依据场地边界(规格)来规划自己的行为。围栏画得越粗糙(规格越弱),实现者的活动空间越大,但客户的确定性越小;围栏画得越精细(规格越强),客户越安心,实现者越受限。
- 关键规则与最佳实践:规格只描述「客户端可观察到的行为」,不描述内部步骤;规格一旦发布,实现者只能在不改变规格的前提下改代码;如果要改规格本身,必须确认新规格「强于或等于」旧规格(见下文强弱规则),否则所有既有客户都需要复查。
确定性规格与欠定规格(Deterministic vs. Underdetermined Specs)
- 定义与目的:确定性规格(deterministic spec)对每个满足前置条件的输入只允许一个合法输出;欠定规格(underdetermined spec)允许多个合法输出,把「选哪一个」的自由留给实现者。它主要服务于 Ready for change:实现者可以在不改契约的前提下更换策略。
- 直观解释(”它是什么?”):确定性的规格像「点一份五分熟的牛排」——结果唯一;欠定的规格像「随便来一份主食」——只要端上来的是主食就算合格。注意 6.031 特意区分了「欠定(underdetermined)」与「非确定(nondeterministic)」:非确定指的是代码在相同输入下时而这样、时而那样(依赖随机数或并发时序);而欠定的规格完全可以由一段完全确定的实现来满足,欠定性只是「契约允许的选择空间」。
- 关键规则与最佳实践:写规格时先问「客户是否真的在意返回哪一个?」;如果不在意,用欠定规格换取实现自由(例如「返回任意一个满足 arr[i] == val 的 i」);如果在意,就必须把它写进后置条件(例如「返回满足条件的最小下标」),否则客户不能依赖它;欠定不等于「没说清」——「返回任意一个出现位置」是精确的欠定,而「返回一个差不多对的下标」则是烂规格。
声明式规格与操作式规格(Declarative vs. Operational Specs)
- 定义与目的:操作式规格(operational spec)以「步骤」描述方法怎么做(伪代码式描述属于此类);声明式规格(declarative spec)只刻画最终结果的性质以及它与初始状态的关系。声明式规格通常更短、更好懂,并且不会意外泄漏实现细节,因此同时服务于 Easy to understand 与 Ready for change。
- 直观解释(”它是什么?”):声明式说「结果是什么」,操作式说「过程怎么走」。就像菜谱:声明式是「一盘麻婆豆腐」,操作式是「先切葱、再下锅、翻三下、起锅」——后者一旦被别人当成验收标准,厨师就再也不能改用别的做法了。
- 关键规则与最佳实践:规格注释里永远不要写实现解释(那是给维护者看的,应写在方法体内部);同一份行为往往有多种声明式写法(用后缀拼接、用 substring、用前 prefix.length() 个字符等),要挑对客户和维护者最清晰的一种;当你想写「先……再……最后……」时,先停下来问:客户真的需要知道中途状态吗?只有像
addAll那样「中途失败会留下部分效果」时,阶段信息才必须写进规格。
规格的强弱(Stronger vs. Weaker Specs)
- 定义与目的:若满足规格 S2 的实现集合是满足 S1 的实现集合的真子集,则称 S2 比 S1 强(stronger)。强弱是判断「能否安全替换规格」的唯一标准,直接服务于 Ready for change。
- 直观解释(”它是什么?”):强弱来自谓词逻辑——谓词 P 强于 Q,意味着满足 P 的状态集合更小。规格整体是「关于实现的谓词」,所以规格越强 = 约束越多 = 合法实现越少。可以记成「强的更紧、弱的更松」。
- 关键规则与最佳实践:核心判定规则是——S2 强于或等于 S1,当且仅当 S2 的前置条件弱于或等于 S1 的前置条件,且 S2 的后置条件(在 S1 前置条件成立的输入上)强于或等于 S1 的后置条件。由此得到两条操作口诀:前置条件可以随时放宽(对客户要求更少,永不伤害客户),后置条件可以随时加强(对客户承诺更多,也永不伤害客户)。两个规格可能互不可比(incomparable):既不是子集关系,也没有重叠或完全分离,此时替换必须逐个客户审查。
规格空间图(Diagramming Specifications)
- 定义与目的:把所有可能的实现想象成一个巨大空间里的点集,一份规格就划出其中一片区域,实现要么落在区域内(满足规格),要么在区域外。这个心智模型用于直观理解「强弱」「不可比」与「防火墙」。
- 直观解释(”它是什么?”):
findFirst与findLast是两个点(实现),不是区域(规格);它们都落在findOneOrMore,AnyIndex这片区域里,因此彼此可替换。加强后置条件会缩小区域(要求更高的输出),放宽前置条件也会缩小区域(要求处理更多输入,那些以前被排除的坏行为现在暴露了),所以越强的规格区域越小,越弱的规格区域越大。两个不可比的规格可能重叠、也可能完全分离。 - 关键规则与最佳实践:判断包含关系时,分别看两个方向——「在 S1 区域里的实现是否一定在 S2 区域里」以及「在 S2 区域里的实现是否一定在 S1 区域里」;只要有一个方向不成立,就不存在单向的强于关系;用图来解释「为什么不能随手削弱后置条件」最有效:那会让一批既有客户脚下突然出现空白区。
前置条件还是后置条件(Precondition or Postcondition? Fail Fast)
- 定义与目的:设计规格时必须决定:某个要求是写成前置条件(客户的责任),还是写成后置条件(实现者负责检查并抛出异常)?这关系到 Safe from bugs:越早失败,越容易定位 bug。
- 直观解释(”它是什么?”):前置条件实质上是「把检查成本转嫁给客户」。所以前置条件最常见的用途,恰恰是那些实现者检查起来很贵或很难的性质:例如用二分查找实现
find时要求数组已排序,如果强制实现者去验证有序性,就会把对数时间变成线性时间,二分查找的意义荡然无存。 - 关键规则与最佳实践:非平凡的前置条件会给客户添麻烦——一旦违反,程序没有任何可预期的恢复方式,所以用户不喜欢前置条件;因此 Java API 与许多 JavaScript 库倾向于用后置条件规定:参数不合适时抛出未检查异常(unchecked exception),让调用方的错误假设立刻暴露;通用的工程判断标准是两条——检查的代价(写代码与运行时的成本)与方法的作用域(只在类内部调用的私有方法可以用前置条件 + 小心审查所有调用点;对外公开的方法更应抛异常);即使某个接口把要求写成前置条件,只要它同时承诺「违反时抛出特定异常」,那它在语义上就是后置条件。
好规格的准则:连贯、有信息量、足够强也足够弱、抽象类型(Designing Good Specifications)
- 定义与目的:形式(简短、清楚、结构好)容易做到,内容难有定规,但有几条可靠的经验准则,服务于 Easy to understand 与 Ready for change。
- 直观解释(”它是什么?”):一个连贯(coherent)的规格能让客户把它当作一个完整、单一的功能来理解——参数列表很长、用布尔开关切换行为、逻辑错综复杂,都是坏信号(例如
sumFind(int[] a, int[] b, int val)同时「在两个数组里查找」和「把下标求和」,本应拆成两个方法);一个规格的结果应当有信息量(informative)——如果返回null既表示「原来没有这个键」又表示「原来的值就是 null」,返回值就毫无用处;规格要足够强(特例不能毁掉一般用途,例如addAll若允许在抛异常前追加一部分元素,客户就不知道到底追加了什么);也要足够弱(例如open不能保证「一定打开文件」,因为权限与文件系统故障不在程序掌控之中,它只能承诺「尝试打开,若成功则满足某些性质」);能用抽象类型就用抽象类型(返回List而不是ArrayList,接口类型而不是具体实现类),这同时给客户和实现者留出自由。 - 关键规则与最佳实践:先写规格再写实现(设计方法就是设计规格);用「一个完整单元」的标准自查连贯性,发现多职责就拆分;检查特殊返回值是否与正常返回值语义冲突,冲突就用更明确的机制(显式查询、抛异常、
Optional);为每个特例问一句「这个特例会不会让整个方法对客户变得没用」;比较参数与返回值的类型,凡是「比行为所需更具体」的类型都换成抽象类型。
信息隐藏与自由度(Information Hiding, Access Control & Freedom)
- 定义与目的:规格是信息隐藏(information hiding)的载体:它只暴露客户需要知道的东西,把实现细节挡在契约之外,从而同时保护 Ready for change 与 Easy to understand。
- 直观解释(”它是什么?”):客户看模块是「透过规格这扇窗」看,看不到内部;只要窗上写的东西不变,屋里怎么装修都可以。对应到 Java 语言层面,
public/private的选择本身就是在画契约边界:把只供内部使用的辅助方法设为public,等于向全世界承诺「我会一直提供这个方法」,将来就难以改动内部实现,还让公开接口变得杂乱(接口越小越连贯)。 - 关键规则与最佳实践:默认把字段与内部辅助方法声明为
private,只在确实要对外提供服务时用public;规格注释中不要提及任何内部变量、内部数据结构或算法步骤;用抽象类型(如List、Map)而不是具体类型(如ArrayList、HashMap)书写规格;当发现自己想用规格注释「顺便解释实现」时,把那段话移到方法体里。
代码示例与对比分析
场景 1:用实现步骤写 find 的规格,而且把「最小下标」这一实现细节写成了契约
❌ 错误代码
// 错误:规格写成操作式,并且把 findFirst 的实现特征(返回最小下标、
// 找不到时返回 arr.length)当成了契约的一部分。
/**
* 从头开始扫描数组:i = 0, 1, 2, ...,
* 每次比较 arr[i] == val,一旦相等就返回 i,
* 如果走完整个数组都没找到,就返回 arr.length。
*/
public static int find(int[] arr, int val) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == val) return i;
}
return arr.length;
}
【错误代码的问题】
- 泄漏实现细节:规格里写死了「从头开始扫描」「返回最小下标」「未找到返回 length」,任何客户只要读了这个注释就可能依赖这些细节。
- 排除合法实现:按这份规格,
findLast(从尾部扫描、未找到返回 -1)不满足契约,但它其实是完全合理的实现,实现者的自由度被白白剥夺(违背 Ready for change)。 - 语义含糊:arr.length 这个「未找到」的哨兵值在 Java 中会被静默当作一个合法下标,客户若忘记检查就会得到
ArrayIndexOutOfBoundsException(违背 Safe from bugs)。 - 文档与代码重复:注释逐句翻译了方法体,一旦代码改动,注释立刻过期,成为误导读者的隐患(违背 Easy to understand)。
✅ 正确代码
// 正确:声明式规格 + 允许两种实现的欠定后置条件。
// 两个实现都满足下面这一份规格,因此它们可以互相替换。
/**
* 在数组中查找一个值。
* @param arr 被搜索的数组
* @param val 要查找的值
* @return 满足 arr[i] == val 的下标 i
* requires: val 在 arr 中至少出现一次
*/
public static int find(int[] arr, int val) { /* 见下方两种实现 */ return 0; }
// 实现 A:从前往后扫描
public static int findFirst(int[] arr, int val) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == val) return i;
}
return arr.length; // 前置条件保证不会走到这里
}
// 实现 B:从后往前扫描
public static int findLast(int[] arr, int val) {
for (int i = arr.length - 1; i >= 0; i--) {
if (arr[i] == val) return i;
}
return -1; // 前置条件保证不会走到这里
}
【为什么这样更好】 规格只声明「返回某个满足 arr[i] == val 的下标」,这是一个精确的欠定后置条件:它对客户是完整的信息(客户知道拿到的下标一定命中),对实现者则留出了「从哪头扫」的自由。findFirst 与 findLast 都落在这份规格划定的区域内,实现者可以在区域内任意改动(换算法、优化缓存局部性),客户一行代码都不用改。
【代码对比解说】 错误写法与正确写法的差别不在代码,而在注释的语义层级:前者把「实现的控制流」提升成了「契约」,后者把「结果的性质」写成了契约。判断标准很简单——问自己「客户有没有权利依赖这一点?」。客户有权依赖「返回的下标处确实是 val」,但无权依赖「返回的是最小下标」,除非规格明确承诺。如果业务确实需要最小下标,那也应该写成后置条件(findOneOrMore,FirstIndex:返回最低下标),而不是描述扫描方向——这两者结果相同,但前者是实现无关的声明,后者是操作式描述。
【设计原则透视】 这是「规格作为防火墙」的直接体现,也是抽象边界(abstraction boundary)的维护:契约层只谈可观察行为,实现层才谈步骤。它同时展示了「欠定规格」的正确用法——欠定不是模糊,而是把实现选择权明确地留给实现者。与 Reading 6(Specifications)中的前置/后置条件结构完全一致,也为 Reading 10、11(抽象数据类型、抽象函数与表示不变量)中「客户只能看见 AF 所描述的抽象值」埋下伏笔。
场景 2:startsWith 的规格写成伪代码步骤,而不是结果的性质
❌ 错误代码
// 错误:操作式规格——把「怎么比」写了进去,还泄漏了内部实现可能用的下标范围。
/**
* 令 i 从 0 开始,每次把 str.charAt(i) 与 prefix.charAt(i) 比较,
* 若不同则立即返回 false,若相同则 i 加一,直到 i 等于 prefix.length(),
* 此时返回 true。
*/
public static boolean startsWith(String str, String prefix) { /* ... */ return false; }
【错误代码的问题】
- 不可用的契约:客户无法从「i 从 0 开始循环」推断出任何可直接使用的结果性质,只能自己脑内模拟循环。
- 绑定实现:把「逐个字符比较」写进契约,排除了任何其他实现(例如先用
str.substring(0, prefix.length()).equals(prefix)的写法),也排除了未来用更快的原生实现替换的可能。 - 边界情况没交代:操作式描述没有说明
prefix比str长时会怎样,客户的疑问依然存在(违背 Easy to understand)。
✅ 正确代码
// 正确:三种等价的声明式规格,任选其一(以下是推荐的第一种,最贴近“拼接”的直觉)。
/**
* 判断 str 是否以 prefix 开头。
* @param str 被检查的字符串
* @param prefix 前缀
* @return 当且仅当存在字符串 suffix 使得 prefix + suffix 等于 str 时返回 true
*/
public static boolean startsWith(String str, String prefix) {
return str.startsWith(prefix); // 也可以用下面的等价实现
}
// 等价的声明式写法之二:存在某个整数 i 使得 str.substring(0, i).equals(prefix)
// 等价的声明式写法之三:str 的前 prefix.length() 个字符与 prefix 的字符完全相同
【为什么这样更好】 后置条件是一句可以直接当数学性质使用的断言:「存在 suffix 使 prefix + suffix = str」。客户读一遍就知道所有边界情况(包括 prefix 为空串时恒为 true、prefix 比 str 长时不可能是后缀拼接的结果),实现者也可以自由选择任何等价实现。三种声明式写法在语义上完全等价,选择哪一种只关乎「对读者最清楚」。
【代码对比解说】 声明式写法都有一个共同特征:它们在描述输出与输入的数学关系(存在量词、下标约束、字符相等),而不是描述机器的动作序列。要注意「等价」并不意味着可以混着写——不要在声明式规格里夹一句「它先比较首字符」,那会把客户重新拉回实现细节。此外,如果一段实现解释对维护者真的有用(例如「这里用了 Boyer-Moore 以加速」),应该写成方法体内部的普通注释,而不是规格注释。
【设计原则透视】 这体现了抽象边界的两侧分工:规格面向客户,只谈可观察性质;实现注释面向维护者,才谈步骤与技巧。它与本讲「声明式优于操作式」的结论一致,也是 Reading 4(Code Review)中「注释要写规格而非代码翻译」原则的延伸。
场景 3:put 的返回值用 null 同时表示「原来没有键」和「原来的值就是 null」
❌ 错误代码
// 错误:前置条件允许 val 与 map 中存 null,后置条件却用 null 表示“键不存在”,
// 返回值因此毫无信息量。
/**
* requires: val 可以为 null,map 中也可以存放 null 值
* effects: 把 (key, val) 插入映射,覆盖 key 原有的映射,
* 并返回 key 原来的值;如果原来没有映射,则返回 null
*/
public static <K, V> V put(Map<K, V> map, K key, V val) {
return map.put(key, val); // 无法区分“没有键”与“原值为 null”
}
【错误代码的问题】
- 信息丢失:调用方拿到
null时无法判断键是「从未存在」还是「存在且值为 null」,只能靠自己额外维护状态。 - 契约自相矛盾:前置条件主动允许 null 值存在,后置条件又拿 null 当哨兵,等于在同一份契约里让一个值承担两种互斥含义。
- 诱导错误代码:客户为了区分情况,往往会写
if (map.containsKey(key)) {...} else {...}再调用一次put,既重复又引入竞态窗口(在并发场景下更危险)。
✅ 正确代码
// 正确:让“没有键”这一情况拥有自己的表示,而不是借用 null。
// 补充说明:Optional 来自 Java 8 的 java.util,不属于 6.031 原文用法,
// 这里作为“结果要有信息量”这一原则的 Java 生态实现方式给出。
/**
* 把 (key, val) 插入映射,覆盖 key 原有的映射。
* @return 键原有的值;若该键此前没有映射,返回 Optional.empty()
*/
public static <K, V> Optional<V> put(Map<K, V> map, K key, V val) {
final boolean hadKey = map.containsKey(key);
final V old = map.put(key, val);
return hadKey ? Optional.ofNullable(old) : Optional.empty();
}
【为什么这样更好】 「没有键」与「值为 null」被编码成两个可区分的返回值(Optional.empty() 与 Optional.of(null)——实际上 Optional.ofNullable(null) 会得到 Optional.empty(),因此更稳妥的写法是让契约禁止 null 值,或用第三个状态、抛异常等机制),客户只需一次调用、一次判断就能得到完整信息。这也是「结果应当有信息量」这条准则的最直接应用。
【代码对比解说】 两份代码的实现几乎一样(都是 map.put),差别全在返回值的语义设计上。注意上面正确版本仍有一个残留陷阱:如果 val 与 old 都允许是 null,Optional.ofNullable 依旧会混同两者——所以在真实设计中通常要二选一:要么在前置条件中禁止 null(让 Optional 精确表达「键缺失」),要么用「是否包含键 + 值」双重查询。这正好说明:规格设计不是加个新类型就完事,必须让新机制在同一份契约内保持语义无歧义。
【设计原则透视】 这是「规格应当有信息量」与「前置条件/后置条件要一致」两条准则的交点。它也提醒我们:契约中出现的每个特殊值都必须有唯一定义,否则客户的推理链(以及 Reading 3 的测试设计)会出现漏洞。这里的选择(禁止 null vs 引入显式缺失值)本质上是在权衡「前置条件强度」与「后置条件表达能力」。
场景 4:addAll 的规格太弱——抛异常前允许已追加一部分元素
❌ 错误代码
// 错误:规格没有说明失败时的原子性,实现按“边检查边追加”写,
// 于是异常抛出时 list1 已经被改了一部分。
/**
* effects: 把 list2 中的元素按顺序追加到 list1 末尾,
* 除非在 list2 中遇到 null 元素,此时抛出 NullPointerException
*/
public static <T> void addAll(List<T> list1, List<T> list2) {
for (T t : list2) {
if (t == null) throw new NullPointerException("list2 contains null");
list1.add(t);
}
}
【错误代码的问题】
- 失败后状态不明:客户捕捉到异常后完全不知道
list1里到底追加了哪些元素,必须自己写代码去比对,而这份信息理论上只有实现者才知道。 - 规格太弱以致无用:这份规格虽然比「不提 null 元素」的版本强,但仍不足以让客户安全地使用——常见做法只能整体拒绝这个结构。
- 难以测试:测试用例无法写出确定的期望结果(是「抛异常」还是「抛异常 + list1 部分改变」?),违背 Reading 3(Testing)中「测试要能断言确定结果」的要求。
✅ 正确代码
// 正确:强化后置条件,明确失败时的原子性——先检查,再一次性追加。
/**
* effects: 把 list2 中的元素按顺序追加到 list1 末尾。
* 如果 list2 中含有 null 元素,则抛出 NullPointerException,
* 并且不向 list1 追加任何元素。
*/
public static <T> void addAll(List<T> list1, List<T> list2) {
if (list2.contains(null)) {
throw new NullPointerException("list2 contains null");
}
list1.addAll(list2); // 检查通过后一次性完成,不留部分效果
}
【为什么这样更好】 强化后的规格把「失败时的状态」也变成契约的一部分:要么完全成功,要么完全不改(原子性)。客户因此可以在 catch 块里安心地继续使用 list1,测试也能精确断言。注意这里的强化方向符合本讲的强弱规则——加强后置条件对客户永远是无害的,因为它只是多给了一个承诺。
【代码对比解说】 两份实现的行为差异只在「检查时机」:错误版本把检查与追加交织在一起(对应规格里的「unless it encounters…」),正确版本先整体验证再整体执行。从规格角度看,前者把「中途状态」暴露给客户,后者把它隐藏起来;从性能角度看,contains(null) 多走一遍列表,属于用一点点开销换取契约的可用性——这正是「规格强弱是工程判断」的典型例子。
【设计原则透视】 这条对比把「规格应当足够强」落到了具体代码上:规格强度不足时,客户必须承担额外的推理负担甚至引入自己的容错代码。它还示范了「异常作为后置条件」的写法(与 Reading 6 一致),并说明了「先检查后执行」可以同时改善 Safe from bugs 与 Easy to understand。
场景 5:规格里用具体实现类型 ArrayList,把客户与实现者同时锁死
❌ 错误代码
// 错误:规格依赖具体实现类型,客户必须传 ArrayList,
// 实现者也只能返回 ArrayList,即使它内部用的是更合适的 List 实现。
/**
* @param list 待反转的列表
* @return 反转后的新列表,newList[i] == list.get(n - i - 1),n == list.size()
*/
public static ArrayList<String> reverse(ArrayList<String> list) {
final ArrayList<String> result = new ArrayList<>();
for (int i = list.size() - 1; i >= 0; i--) {
result.add(list.get(i));
}
return result;
}
【错误代码的问题】
- 无谓地限制客户:行为与
ArrayList的任何具体特性无关,客户却被迫把数据放进ArrayList;传List.of(...)产生的不可变列表就直接编译失败。 - 无谓地限制实现者:实现者被迫构造并返回
ArrayList,无法返回Collections.unmodifiableList(...)之类的视图,也难以改成返回链表等更合适的结构。 - 削弱可修改性:将来若要把实现换成别的数据结构,签名变更会波及所有调用点(违背 Ready for change)。
✅ 正确代码
// 正确:规格使用抽象类型 List,客户端与实现端都获得自由。
/**
* @param list 待反转的列表
* @return 反转后的新列表,newList[i] == list.get(n - i - 1),n == list.size()
*/
public static List<String> reverse(List<String> list) {
final List<String> result = new ArrayList<>();
for (int i = list.size() - 1; i >= 0; i--) {
result.add(list.get(i));
}
return Collections.unmodifiableList(result); // 实现细节,客户无需知道
}
【为什么这样更好】 抽象类型是「规格层面」与「实现层面」的解耦点:客户只需提供任何 List(包括不可变视图),实现者可以自由挑选内部结构并返回 List 的任何合法实现。Java 中这意味着尽量用接口类型(List、Set、Map、Reader)而不是具体类(ArrayList、HashSet、HashMap、FileReader)。
【代码对比解说】 两份代码的算法完全相同,唯一区别是签名里写 ArrayList 还是 List。但这一字之差改变了契约的「作用域」:具体类型把契约绑定到某个实现的细节上,抽象类型只绑定到行为。注意正确版本顺便演示了一个好习惯——返回不可变视图(见 Reading 8: Mutability & Immutability),这是实现者的自由,且不改变规格承诺。
【设计原则透视】 这是信息隐藏(information hiding)在签名层面的落实,也是 Reading 12(Interfaces, Generics, Enums)中「面向接口编程」的前置铺垫。它同时体现了本讲的用法:规格只承诺客户关心的东西,凡是行为不需要的具体类型都应当抽象掉。
场景 6:countLongWords 规格不连贯,一个方法干三件事
❌ 错误代码
// 错误:规格不连贯——同时改全局变量、打印到控制台、还提到局部变量 words。
public static int LONG_WORD_LENGTH = 5;
public static String longestWord;
/**
* 把 longestWord 更新为 words 中最长的元素,
* 同时把长度大于 LONG_WORD_LENGTH 的元素个数打印到控制台。
* @param text 要搜索的文本
*/
public static void countLongWords(String text) {
// ... 实现略:既更新全局变量,又打印,还依赖未说明的 words
}
【错误代码的问题】
- 不连贯:把「统计长单词」和「找出最长单词」两件不相关的事塞进一个方法,客户为了其中一件必须承受另一件的副作用。
- 通过全局变量通信:结果写到
public static字段里,任何代码都能在任意时刻改它,bug 的影响范围无法界定(违背 Safe from bugs,也为 Reading 9 中的「作用域最小化」提供了反面教材)。 - 打印而非返回:输出无法被测试断言,也无法被其他代码复用(违背 Reading 3 的测试原则)。
- 规格提到局部变量
words:契约暴露了实现内部的名字,客户读不懂,维护者也容易被过期名称误导。
✅ 正确代码
// 正确:拆成两个连贯的方法,各自返回结果,不碰全局状态。
/**
* @param text 要搜索的文本
* @return text 中长度大于 minLength 的单词个数
*/
public static int countLongWords(String text, int minLength) { /* ... */ return 0; }
/**
* @param text 要搜索的文本
* @return text 中最长的单词;若没有单词则返回 Optional.empty()
*/
public static Optional<String> longestWord(String text) { /* ... */ return Optional.empty(); }
【为什么这样更好】 每个方法只做一件事,规格可以写成一句话;返回值取代了全局变量与打印,于是同一份逻辑既能被测试、又能被复用;minLength 作为参数取代全局常量,参数化之后方法在其他上下文中也能使用(Ready for change)。
【代码对比解说】 注意错误版本其实还违反了 DRY(它需要自己把文本切成单词两次),拆分之后「分词」这段逻辑可以抽成一个私有辅助方法被两者共用。这里的取舍是「方法数量变多」对「每个方法更容易理解、测试、复用」——6.031 明确选择后者,因为模块化本身就是把 bug 局部化的手段(见 Reading 9)。
【设计原则透视】 规格的连贯性直接对应抽象边界:一个方法应当只承诺一件事,这样它的前后置条件才可能被一句话说清。不连贯的规格几乎必然伴随全局状态与副作用,而这两者又会让表示不变量与并发推理变得困难(Reading 11、Reading 21/23)。
场景 7:规格强弱与安全替换——从 findExactlyOne 到 findOneOrMore,FirstIndex
❌ 错误代码
// 错误:把弱规格当成强规格来“升级”——把前置条件改强、后置条件改弱,
// 结果是既有客户全部失去保护。
/**
* requires: val 在 a 中恰好出现一次
* effects: 返回满足 a[i] == val 的下标 i
*/
public static int findExactlyOne(int[] a, int val) { /* ... */ return 0; }
// 有人为了“更精确”,改成了下面这份规格:
/**
* requires: val 在 a 中出现奇数次,且 a 已按升序排序
* effects: 返回某个满足 a[i] == val 的下标 i,或者 -1
*/
public static int findOddSorted(int[] a, int val) { /* ... */ return -1; }
【错误代码的问题】
- 前置条件变强:原来是「恰好出现一次」,现在额外要求「出现奇数次」并且「数组升序」,一批原本合法的调用突然违约。
- 后置条件变弱:原来保证返回的下标一定命中,现在允许返回 -1,客户原来那句
a[find(a, val)] == val可能直接抛数组越界。 - 不可安全替换:两个方向的改动都缩小了客户能依赖的保证,等于同时攻击了所有既有调用点(违背 Ready for change 与 Safe from bugs)。
- 规格之间不可比:新旧规格既不是包含也不是被包含,属于 incomparable,无法用强弱规则一次性判断安全性,必须逐个调用点审查。
✅ 正确代码
// 正确:沿“放宽前置条件、加强后置条件”的方向逐级强化,每一步都可安全替换。
// S1(最弱):
// requires: val 在 a 中恰好出现一次
// effects: 返回 satisfying a[i] == val 的下标 i
//
// S2 强于 S1:前置条件放宽为“至少出现一次”,后置条件不变
/**
* requires: val 在 a 中至少出现一次
* effects: 返回满足 a[i] == val 的下标 i
*/
public static int findAnyIndex(int[] a, int val) { /* ... */ return 0; }
// S3 强于 S2:前置条件不变,后置条件加强为“最低下标”
/**
* requires: val 在 a 中至少出现一次
* effects: 返回满足 a[i] == val 的最低下标 i
*/
public static int findFirstIndex(int[] a, int val) {
for (int i = 0; i < a.length; i++) {
if (a[i] == val) return i;
}
throw new AssertionError("precondition violated: val not in a");
}
【为什么这样更好】 强化规格的两个方向都只增加客户能依赖的东西:S2 让更多输入变得合法(客户更容易满足前置条件),S3 让输出更确定(客户可以依赖「一定是最低下标」)。因此满足 S3 的实现自动满足 S2 与 S1,把旧规格替换为新规格永远不会让既有客户失效。要注意 S2 这类「至少出现一次」的规格是欠定的:它允许实现返回任意命中下标,findFirst 与 findLast 都合法。
【代码对比解说】 判断强弱的机械流程是:先比较两个前置条件的集合大小(谁更小谁更强),再在旧前置条件成立的输入范围内比较后置条件(谁约束更多谁更强);只有「前置不更强」且「后置不更弱」时,才能宣布 S2 ≥ S1。注意上面 S3 的实现里用 throw new AssertionError 表示前置条件被违反——这不是后置条件的一部分,而是「实现不再受契约约束」时的快速失败(见 Reading 9: Avoiding Debugging)。
【设计原则透视】 这条对比是本讲最强的可操作规则:替换规格时只能放宽前置条件、加强后置条件。它把「规格空间图」中的区域包含关系变成了可执行的代码演化策略,也解释了为什么 findCanBeMissing(前置条件为 nothing、但允许返回 -1)与 findOneOrMore,FirstIndex 互不可比:前者前置更弱(区域更大),但后置也更弱(在共同输入上少了「最低下标」的保证),两个方向的变动互相抵消。
与其他设计原则的关联
- Reading 6(Specifications):本讲是它的直接延续。Reading 6 建立了前置条件、后置条件与「客户—实现者契约」的基本结构,本讲则在此之上增加了三个比较维度(确定性、声明性、强度)与「如何写出好规格」的准则。没有 Reading 6 的契约语言,本讲的强弱规则无从表述。
- Reading 3(Testing)与 Reading 4(Code Review):规格是测试的唯一依据——测试用例的合法性由「输入是否满足前置条件」决定,期望结果由后置条件决定(本讲场景 4 中「规格太弱无法写测试」正是这一点)。Reading 4 强调「注释要写规格、不要翻译代码」,与本讲「声明式优于操作式」是同一条规则的不同表述。
- Reading 8(Mutability & Immutability):当方法会修改输入对象时,必须在
effects(或Modifies子句)中显式声明,否则「未声明的修改一律视作禁止」。本讲场景 5 中返回Collections.unmodifiableList的写法,正是把不可变性作为实现自由来使用。 - Reading 9(Avoiding Debugging):本讲「前置条件还是后置条件」的讨论与 Reading 9 的断言(
assert)直接衔接:把参数要求写成断言,是让违反契约的调用尽早、就近失败的标准做法;而「外部条件用异常、内部假设用断言」的分工也源自同一个「fail fast」原则。 - Reading 10、11(Abstract Data Types;Abstraction Functions & Rep Invariants):本讲场景 5 的「抽象类型」要求在此彻底展开——ADT 的规格其实就是抽象函数(AF)的说明,而 ADT 的
checkRep检查的是表示不变量(RI),二者共同构成「抽象边界」两侧的完整契约。 - Reading 12(Interfaces, Generics, Enums):规格中优先使用接口类型而非实现类的做法,在泛型与接口章节成为语言级惯例(
List、Map、Function)。 - Reading 21、23(Concurrency;Locks):欠定规格在并发中会变成「允许哪些交错」的问题,而「放宽前置条件」在并发场景下需要额外小心——详见并发章节对线程安全规格的讨论。
关键要点
- 规格有三个可比维度:确定性、声明性、强度。 先问「是否只允许一个输出」,再问「是在描述结果还是在描述步骤」,最后问「合法实现集合有多大」。
- 欠定规格 ≠ 模糊规格,也 ≠ 非确定实现。 「返回任意一个命中下标」是精确的欠定承诺,通常由一个完全确定的实现来兑现;欠定的价值在于给实现者留出选择空间。
- 永远用声明式写法。 后置条件只谈输出与输入的关系(存在量词、下标约束、等值关系),不谈控制流;实现解释写在方法体内部而不是规格注释里。
- 规格替换只允许单向操作:放宽前置条件、加强后置条件。 判定公式是「S2 前置 ≤ S1 前置 且 S2 后置 ≥ S1 后置(在 S1 前置成立的输入上)」;方向相反或互不可比时,必须逐个调用点审查。
- 前置条件是「把检查成本转嫁给客户」的工具,只在检查代价高或方法作用域小时才使用。 对外公开的方法宁可写「参数非法时抛出未检查异常」这样的后置条件,以便快速失败。
- 好规格的六条准则:连贯、结果有信息量、足够强、足够弱、使用抽象类型、形式简洁清晰。 遇到长参数列表、布尔开关、一堆特例、与实现耦合的类型,都要重构规格本身。
常见陷阱与注意事项
- 把实现步骤写进规格注释(操作式规格) → 客户依赖实现细节;实现者一旦改算法就被指责「违反契约」,Readiness for change 直接归零。正确做法是把步骤描述移到方法体内部注释。
- 用
null、-1、arr.length之类的哨兵值兼作两种含义 → 客户无法区分「没有结果」与「结果就是特殊值」,返回值失去信息量;一旦客户据此写分支逻辑,后续任何语义调整都会引发连锁 bug。 - 误以为「把前置条件改强 = 规格更严谨、更好」 → 实际上前置条件越强,客户越难调用、合法实现越多(区域越大、规格越弱),既有客户可能全部违约。记住口诀:前置只能放宽,后置只能加强。
- 在规格中泄漏具体实现类型(
ArrayList、HashMap、FileReader) → 客户被迫使用特定结构,实现者也被锁死在某个返回类型上;签名一变,所有调用点都要改。 - 认为「欠定 = 允许实现随便乱来」 → 欠定只放开了「多个合法输出之间的选择」,绝不放开后置条件本身;如果规格允许返回任意值,那不是欠定,而是没有规格。
- 把「检查前置条件」的代码当成后置条件写进规格 → 例如声称「要求数组有序」却同时承诺「乱序时抛出特定异常」,这在语义上已经变成后置条件,实现者必须真的去检查;分不清这一点会导致实现与文档不一致。
- 规格不连贯(一个方法既查找又打印又改全局变量) → 无法一句话说清契约,客户为了用一半功能必须接受全部副作用;正确做法是按职责拆分成多个连贯方法。
思考题(带答案)
问题 1:设有两份 find 规格: (a)requires: val 在 a 中恰好出现一次;effects: 返回满足 a[i] == val 的下标 i; (b)requires: nothing;effects: 返回满足 a[i] == val 的下标 i,若不存在则返回 -1。 请判断二者是否可比、哪一份更强,并说明 findFirst(返回最小下标、找不到返回 a.length)与 findLast(返回最大下标、找不到返回 -1)分别满足哪一份。
答案:二者不可比(incomparable)。相对(a),(b)的前置条件更弱(从「恰好一次」放宽到「无要求」),这一方向的改动使(b)更强;但在(a)前置条件成立的输入上,(b)的后置条件也更弱(允许返回 -1,而(a)保证一定命中),这一方向的改动使(b)更弱。两个方向相互抵消,因此不存在包含关系,也不存在完全分离,属于部分重叠。至于实现:findFirst 与 findLast 在满足(a)前置条件的输入上都返回正确下标,因此都满足(a);它们对「找不到」的输入分别返回 a.length 与 -1,两者都不是 -1(findFirst 返回 a.length),所以 findFirst 不满足(b),而 findLast 满足(b)。这也说明:同一个实现可以满足互不可比的多份规格,但一份规格的客户不能假定另一份规格的保证。
问题 2:某实现者想给 sort(List<String> list) 加一个更「聪明」的规格:effects: 把 list 排成升序并返回 list 中的元素个数;若 list 中含有 null 元素,则抛出 NullPointerException。请指出这份规格至少两个设计问题,并给出改进方向。
答案:第一,不连贯——方法既做排序又返回元素个数,这两件事没有关系,客户想数元素却被迫触发排序(或被迫接受排序带来的性能开销)。应拆成两个方法:sort(返回 void,修改 list)与 size(只读)。第二,特例削弱了可用性:规格没有说明抛出异常时 list 处于什么状态(有没有被排到一半),客户捕获异常后无法判断后续能否继续使用该列表;应像 addAll 那样强化后置条件,明确「若抛出异常,list 保持不变」并据此实现(先检查 null、再排序),或者干脆把 null 要求写成前置条件、让客户自己保证。第三(附加):返回元素个数在 Java 中与 list.size() 重复,属于无信息量的返回值。此外如果保留修改语义,规格里必须显式写出「修改 list」这一点,否则默认不允许修改输入。
问题 3:为什么不建议在没有特殊理由时为 find 写出「返回最低下标」这样的确定性后置条件?请结合「实现者自由度」与「客户可理解性」两方面权衡回答。
答案:确定性规格承诺更强,对客户当然更友好——客户可以依赖「返回的就是最低下标」做进一步的推理(例如推断前缀里没有 val),这是它作为后置条件加强的合法方向,任何既有客户都不会因此受损。但代价是实现者自由度下降:一旦承诺最低下标,任何实现都必须保证这一性质,例如想做「从尾部扫描 + 缓存」或并行分段查找的实现就会被迫额外处理下标比较,规格区域随之缩小(findOneOrMore,FirstIndex 区域小于 findOneOrMore,AnyIndex)。因此关键问题是:客户是否真的需要确定性那一部分信息。若客户只关心「拿到一个命中位置」,欠定规格即可,实现者可在区域内自由选择算法(这在 Reading 8、10 中体现为可以自由改用不同数据结构);若客户确实依赖最低下标(例如要求「稳定」的行为或用于断言前缀属性),就应明确写成确定性后置条件并接受实现受限。这正是「规格应当足够强、也应当足够弱」这条准则的具体含义:规格强度不是越高越好,而是恰好覆盖客户的真实需求。
