Reading 6: 规格说明(Specifications)
Reading 6: 规格说明(Specifications)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。凡属笔记额外补充的 Java 生态知识(如
Optional、Objects.requireNonNull、受检异常),均以「补充说明」标注。
概述
规格说明(specification)是团队协作的关键枢纽(linchpin):不写规格说明,就不可能把”实现某个方法”的责任委派给别人。规格说明是一份契约(contract)——实现者负责满足契约,使用该方法的客户(client)则可以依赖契约;而且像真实的法律合同一样,契约对双方都提出要求:当前置条件存在时,客户也有责任。
本讲要回答的核心问题有四组:第一,什么是行为等价(behavioral equivalence)——什么时候可以把一个实现换成另一个而不破坏客户?答案取决于客户究竟依赖了什么,而”客户依赖什么”必须由规格说明精确写出。第二,规格说明的结构是什么——函数签名、requires 子句、effects 子句,它们合起来构成前置条件(precondition) 与后置条件(postcondition),整体是一条逻辑蕴含:若调用时前置条件成立,则函数返回时后置条件必须成立;若前置条件不成立,实现不受任何约束,可以做任何事,包括不返回、抛未被规格提及的异常、返回任意结果、做任意修改。第三,规格说明的强弱(weaker / stronger) 如何比较——前置条件更弱、后置条件更强的规格更强,更强的规格对客户更有利、对实现者更苛刻;这一主题在 Reading 7(Designing Specifications)中正式展开,本讲先建立判据。第四,语言特性如何让规格更安全——异常(exceptions)用于区分”信号 bug”与”可预期的失败”,null 的默认禁用与 Optional 的克制使用让”值的缺失”不再模糊,而 TypeScript 的严格空检查在 Java 中对应着依赖静态类型与注解的同类保障。
本讲所有内容都对准三大目标:Safe from bugs(免于 bug)——程序中最难缠的 bug 来自”两个模块在接口处对行为的理解不一致”,规格说明把双方的共同假设写下来,用机器可检查的语言特性(静态类型、异常)替代纯人读的注释,能进一步减少 bug;Easy to understand(易于理解)——一份简短清晰的规格说明比实现本身好懂得多,它让客户不必读源码;Ready for change(为变更而设计)——规格说明在客户与实现者之间筑起防火墙(firewall),双方只要各自遵守契约,就能独立修改,这就是解耦(decoupling)。
核心概念与设计原则详解
行为等价(Behavioral Equivalence)
- 定义与目的:两个实现”行为等价”意味着可以把其中一个替换成另一个而不破坏任何客户。它决定了重构与优化的安全边界。
- 直观解释(”它是什么?”):课程用一个
find方法说明。原版从左到右扫描,找不到返回-1;”聪明版”同时从两端向中间扫描以加速。二者不仅性能不同,输入输出行为也不同:当val在数组中出现多次时,原版总返回最小的下标,聪明版可能返回最小或最大的下标,取决于哪一端先找到。 - 关键规则与最佳实践:
- 行为等价”取决于观察者”——也就是客户:如果客户从不依赖”返回最小下标”这一行为(例如他们总是传入恰好出现一次的
val),那么两个实现就是等价的。 - 要让”可以替换”这件事变成可判断的,就必须有一份规格说明,精确说明客户可以依赖什么。
- 规格说明不必描述所有输入下的行为,只需描述合法调用下的行为。
- 一旦规格说明存在,实现者就获得了”在不违反规格的前提下随意更换实现”的自由——这正是 Reading 4(Code Review)中”为变更而设计”的具体兑现。
- 行为等价”取决于观察者”——也就是客户:如果客户从不依赖”返回最小下标”这一行为(例如他们总是传入恰好出现一次的
规格说明即契约(Specification as a Contract)
- 定义与目的:规格说明是客户与实现者之间的契约,双方各有义务。它服务全部三大目标。
- 直观解释(”它是什么?”):契约是一堵防火墙:它把客户与模块实现细节隔开——作为客户,有规格就不必读模块源码;它同时把实现者与模块使用细节隔开——作为实现者,不必去问每个客户打算怎么用。墙的两侧可以各自改动,只要各自履行义务。
- 关键规则与最佳实践:
- 客户方的义务来自前置条件;实现者方的义务来自后置条件。
- 契约让”甩锅”变得客观:程序失败时能定位到是客户违反了前置条件,还是实现没有满足后置条件——归咎于代码片段,而不是人。
- 规格说明还能让代码更快:它排除了某些调用状态,使实现可以省掉昂贵检查(例如
addAll的前置条件使人可以放心地边遍历边追加)。 - 团队里”每个人心里都有规格”是不够的:不写下来的结果就是不同人心里装着不同规格,程序失败时谁也说不清错在哪。
- 写规格说明不是官僚流程,而是把”接口处的共识”变成可传递的资产。
规格说明的结构(Specification Structure)
- 定义与目的:抽象地说,一个方法的规格说明由三部分组成,它们共同定义了前置条件与后置条件。
- 直观解释(”它是什么?”):
- 函数签名(signature):方法名、参数类型、返回类型。
requires子句:对参数的额外限制。effects子句:返回值、异常以及其他效果。
- 关键规则与最佳实践:
- 签名本身就是前置条件与后置条件的一部分,而且是编译器自动检查的那一部分。
requires的常见内容有两类:收窄参数类型(如”参数x必须是非负整数”)、参数之间的相互作用(如”val在arr中恰好出现一次”)。effects的常见内容有三类:返回值与输入的关系、抛哪些异常以及在什么条件下抛、是否以及如何修改对象。- 在 Java 中,把前置条件写进
@param,把后置条件写进@return与@throws,这是 Javadoc 约定,也是 6.031 要求的形式。 - 用 Javadoc 写规格还能让 IDE(Eclipse、IntelliJ)把信息显示给客户,并自动生成 HTML 文档。
前置条件(Precondition)
- 定义与目的:前置条件是客户的义务,是方法被调用时所处状态上的条件。它主要服务 Safe from bugs。
- 直观解释(”它是什么?”):前置条件回答”在什么情况下允许调用我”。它包括参数的数量与类型(由签名承载),以及额外的限制(写在
requires里)。例如find的规格要求”val在arr中恰好出现一次”。 - 关键规则与最佳实践:
- 前置条件不成立时,实现不受后置条件约束——这是契约的逻辑含义,不是实现者的恶意。
- 因此客户不能通过”故意违反前置条件”来测试实现的行为(如”空数组时它会怎样?”):测试与所有客户一样必须遵守契约。
- 前置条件越弱(要求越少),客户越方便,但实现者的负担越重——这正是强弱权衡的核心。
- 如果前置条件是实现者能够合理检查的,往往应当把它从
requires移到后置条件里(用异常或特殊结果来描述),这样契约更完整、更安全。 - 隐式前置条件同样存在:除非规格说明另作声明,对象与数组类型的参数必须非 null;集合的元素同样必须非 null。
后置条件(Postcondition)
- 定义与目的:后置条件是实现者的义务,是(假设前置条件成立时)方法返回后程序状态上的条件。它主要服务 Safe from bugs 与 Ready for change。
- 直观解释(”它是什么?”):后置条件回答”我必须保证什么”。它包含类型系统能静态检查的部分(尤其是返回类型),以及写在
effects里的额外保证:返回值与输入的关系、会抛哪些异常、会修改什么。 - 关键规则与最佳实践:
- 整体结构是一条逻辑蕴含:前置条件成立 ⇒ 后置条件必须成立。
- 后置条件越强(承诺越多),客户越能依赖,但实现越难写——这与前置条件的方向正好相反。
- 后置条件必须说明副作用:除非明确写出会修改,否则默认不修改任何输入对象(与 null 的默认禁用同理)。
- 后置条件不能承诺超出必要的东西:承诺得越多,实现者越受束缚,未来越难优化——这正是下面
find例子里”返回某个下标”优于”返回最小下标”的原因(在规格设计层面)。 - 后置条件只应谈论参数、返回值与对象状态,不应谈论局部变量或私有字段。
逻辑蕴含(Logical Implication)
- 定义与目的:理解”前置条件不成立时实现是自由的”这一条,是正确使用契约的关键。
- 直观解释(”它是什么?”):把规格写成
pre → post。当pre为假时,整个蕴含式为真,也就是”实现没有违反契约”,无论它做了什么。 - 关键规则与最佳实践:
- 当前置条件被违反时,合法行为包括:返回任意值、抛任意异常(包括规格里没提到的)、修改任意对象、进入死循环、永不返回。
- 因此客户绝不能依赖”违反前置条件时的行为”,也没法测试它。
- 对实现者而言,这条既是自由也是建议:可以在前置条件被违反时抛异常(快速失败,便于调试),但这不是契约要求。
- 课程的练习给出了很好的判据:
find的规格要求val恰好出现一次;那么”arr为空时返回 0”“val出现两次时抛异常”“不满足条件时把数组清零再抛异常”等都被规格允许。 - 注意区分两件事:规格允许什么(任意行为)与好的实现应该做什么(尽早失败、给出可诊断的信息)。
规格说明可以谈论什么(What a Specification May Talk About)
- 定义与目的:规格说明的”话题边界”定义了抽象屏障;越过它,客户就被迫依赖实现细节。
- 直观解释(”它是什么?”):规格可以谈论方法的参数与返回值,绝不能谈论方法的局部变量或所属类的私有字段。对读规格的人来说,实现应当是不可见的——它在防火墙之后。甚至源码本身都可能不可见:Javadoc 工具只抽取规格注释并渲染成 HTML。
- 关键规则与最佳实践:
- 不要在规格里描述算法(”我用 StringBuilder 逐个追加”),那是实现细节;算法可以自由更换。
- 不要用私有字段名出现在
@param/@return中,否则一旦重构字段,规格就变成谎言。 - 规格要描述可观察行为:返回值、异常、对参数对象的修改。
- 规格中的”返回
-1表示未找到”这类约定,实际上是后置条件的一种编码方式,必须写清楚;更好的做法是用异常或Optional让类型本身说明这件事(见下文)。 - 这条规则与 Reading 11(Abstraction Functions & Rep Invariants)正好构成抽象屏障的两侧:对外是规格说明,对内是表示不变量与抽象函数。
规格说明的强弱(Stronger and Weaker Specifications)
- 定义与目的:强弱是比较两份规格说明的判据,决定了”能不能替换”“客户能依赖多少”“实现有多少自由”。本讲先建立直觉与判据,Reading 7(Designing Specifications)会系统地展开。
- 直观解释(”它是什么?”):规格说明 = 前置条件 + 后置条件。若规格 S1 的前置条件更弱(对客户要求更少)且后置条件更强(对实现者承诺更多),则称 S1 比 S2 更强(stronger);反向即更弱。
- 关键规则与最佳实践:
- 更强的规格对客户更好:可以依赖更多保证;对实现者更苛刻:必须处理更多输入、满足更多承诺。
- 因此实现者希望规格更弱,客户希望规格更强——这是接口设计中的核心张力。
- 一个满足更强规格 S1 的实现,也一定满足更弱的规格 S2:因为 S2 允许的输入集合被 S1 覆盖,S2 要求的承诺被 S1 的承诺蕴含。所以”用满足更强规格的实现去替换”是安全的。
- 注意术语陷阱:“前置条件更强”意味着规格整体更弱——因为它缩小了合法调用的集合。句子里的”强”修饰的是条件,不是规格。
- 课程在”测试与规格说明”一节里就用过这组措辞:规格
requires: val occurs in arr加effects: returns index i such that arr[i] = val被认为有强前置条件(它要求客户保证val一定能被找到,即对客户提出了更多要求)与相当弱的后置条件(当val出现多次时,它完全不指定返回哪一个下标)。两者都指向同一个结论:这条规格整体上偏弱,客户能依赖的东西很少——所以客户的测试不能写成assert i == 0。 - 补充说明:Reading 7 会进一步讨论如何在这条谱系上做设计决策(例如”前置条件该不该由实现者来检查”),本讲只需记住判据:更弱的前置 + 更强的后置 = 更强的规格。
规格说明决定客户能依赖什么(What Clients May Depend On)
- 定义与目的:规格说明是”可依赖清单”;超出清单的依赖是 bug 的源头,而不是运气问题。
- 直观解释(”它是什么?”):客户可以依赖规格承诺的一切,也只能依赖这些。实现可能碰巧提供了更强的保证(例如总是返回最小下标),但客户不能依赖这份”碰巧”:它是实现的偶然性质,随时可能在优化中消失。
- 关键规则与最佳实践:
- 客户不应依赖未写下的行为;如果某个行为重要到客户需要它,就必须把它写进规格。
- 客户不应违反前置条件,也不应依赖违反前置条件后的后果。
- 测试也是客户:即使玻璃盒测试(glass box test,见 Reading 3 Testing)了解实现细节,也必须遵守规格,不能断言超出规格的行为。
- 规格带来的好处是双向的:客户省下读源码的成本,实现者省下”问遍所有客户怎么用”的成本。
- 规格说明必须完整到足以判断行为等价:如果它漏写了客户真正依赖的行为,它就失职了。
规格说明与测试(Specifications and Testing)
- 定义与目的:测试必须针对规格编写;单元测试应聚焦单一规格。
- 直观解释(”它是什么?”):黑盒测试(black box test)只依据规格挑选用例;玻璃盒测试(glass box test)借助对实现的了解来挑选用例,但断言仍必须依据规格——目的是”用新用例覆盖实现的不同部分”,而不是”检查实现的私有行为”。
- 关键规则与最佳实践:
- 规格说”返回某个满足
arr[i] = val的下标”时,assert i == 0是错误测试(假设过多),assert arr[i] == val才是正确断言。 - 不要写”违反前置条件会怎样”的测试——那超出了契约范围。
- 单元测试只应依赖被测方法与标准库的规格;
extract()的测试不应因为load()没满足它的后置条件而失败(课程中的搜索引擎例子:load、extract、index)。 - 集成测试(integration test)检验不同模块的规格是否兼容,但不能替代系统设计的单元测试:只通过
index测extract,就只覆盖了load可能产出的那一小部分输入空间,其余部分成为 bug 的藏身处。 - 规格说明为测试提供了”预期行为”的唯一权威来源——这正是测试与规格互为表里的原因。
- 规格说”返回某个满足
异常(Exceptions)
- 定义与目的:异常是方法的一种可能输出,因此可能需要写进后置条件。关键是区分”信号 bug 的异常”与”信号可预期失败的异常”。
- 直观解释(”它是什么?”):
- 信号 bug 的异常:
IndexOutOfBoundsException(下标越界)、NullPointerException(在 null 引用上调用方法)、ArithmeticException(如整数除以零)、NumberFormatException(Integer.parseInt解析失败)。这类异常通常表示客户或实现有 bug,其信息用于帮助定位。它们不是后置条件的一部分,因此不应出现在@throws中——例如NullPointerException永远不该写进规格,因为”参数非 null”已经是隐式前置条件,实现可以在客户违反时自由抛出它。 - 信号可预期失败的异常:用于让调用者能够捕获并响应的情况,例如
lookup(name)在生日簿里找不到名字。这类异常必须用@throws记录,并说明在什么条件下抛出。BirthdayBook的例子展示了这一点:与其返回9/9/99之类的”特殊值”(几十年来坏程序员的做法,而且他们错了),不如抛出NotFoundException,让调用者用catch处理——既不需要特殊值,也不需要配套的检查。
- 信号 bug 的异常:
- 关键规则与最佳实践:
- 信号可预期失败的异常总是写入
@throws;信号 bug 的异常则从不写入。 - 受检异常(checked exception) 在 Java 中还必须在方法签名的
throws子句中声明;来自 Python 或 TypeScript 的读者要特别注意:Java 编译器会静态要求调用者捕获或继续声明,这是 Java 相对 TypeScript 的一个显著优势(TypeScript 不提供异常处理的静态检查,用异常表达可预期失败容易漏掉try...catch)。 - 对未受检但信号可预期失败的异常,Java 允许但不建议写入
throws——写上去会误导人类读者以为它是受检异常;只写@throws即可。 - 自定义异常时,继承
Exception(受检)或RuntimeException(未受检);不要继承Error,那是 Java 保留给自身使用的。捕获时尽量用最具体的异常类,捕获Exception/RuntimeException/Error这类宽泛类型会隐藏 bug、破坏静态检查。 - 异常转译(exception translation):把低层异常(如
PathNotFoundException)转成更高层的异常(如RobotStuckException),可以让模块更好地隐藏实现细节,从而更 ready for change。 - 要小心的反面例子:为了让编译器满意而用
catch (Exception e) { return; }包住整个方法体——它会把真正的 bug(如IndexOutOfBoundsException)也一起静默吞掉,既掩盖缺陷又不可能调试。 - 异常的另一个用途是”最后的手段”:当失败原因不需要客户处理时,返回特殊结果(
null、-1)看似简单,却有两个问题——检查返回值的写法很繁琐,而且很容易忘记检查;用异常反而能得到编译器的帮助(在 Java 中尤其如此)。
- 信号可预期失败的异常总是写入
Null 与 Optional(Avoid Null / Optional)
- 定义与目的:
null表示”引用不指向任何对象”,它是类型系统上的一个洞,最好完全避开;当确实需要表达”值缺失”时,用Optional<T>这类显式载体。 - 直观解释(”它是什么?”):Java 中基本类型不能为
null(int size = null;是静态错误),但任何非基本类型变量都可以被赋值为null:String name = null; int[] points = null;编译器乐于接受,运行时报错——name.length()与points.length都会抛NullPointerException。注意null不等于空字符串或空数组:空串与空数组是合法对象,"".length()是 0;而null上的length()什么都不是,直接抛异常。还要注意”非 null 但元素是 null”的容器:String[] names = new String[] { null };、List<Double> sizes里add(null)——这些 null 会在别人使用容器内容时立刻引发错误。 - 关键规则与最佳实践:
- 约定:除非规格说明明确声明,参数与返回值不允许 null——包括集合(数组、列表、集合、映射)中的元素。
- 每个接受对象/数组参数的方法都隐式带有一个前置条件”非 null”;每个可能返回对象/数组的方法都隐式带有一个后置条件”返回值非 null”。
- 如果某个方法确实允许 null,必须在规格中显式写出(
@param里说明,或使用类型注解如@NonNull/@Nullable;补充说明:Java 生态中用Objects.requireNonNull(x, "x")在入口处快速失败,是常用的落地方式)。 - Google 在 Guava 文档中的说明很有说服力:在 Google 代码库中约 95% 的集合本不该含 null,让它们快速失败而不是静默接受 null 会对开发者有帮助;而且
null语义模糊——Map.get(key)返回null既可能是”键存在但值是 null”,也可能是”键不存在”。用别的东西代替 null 能让含义清晰。 - 当确实需要表示”缺失”时,用
Optional<T>:可以把它想象成一个长度至多 1 的受限制List<T>——要么恰好含一个T,要么为空。isPresent()判断是否为空,get()取出值。它的关键优势是可以克制地使用:只在规格确实允许缺失值的地方出现,从而清楚表达规格意图。(补充说明:Java 8+ 还提供了OptionalInt/OptionalLong/OptionalDouble以避免基本类型的装箱开销。) - 注意”空值”与”缺失”的区别:空值永远是合法的(空串、空列表、空映射都是普通对象),除非规格明确禁止;因此”允许空参数”不需要额外声明,而”允许 null 参数”必须声明。
可变方法的规格说明(Specifications for Mutating Functions)
- 定义与目的:到目前为止的例子都在描述返回值;可变对象上的方法还必须在后置条件里描述副作用。
- 直观解释(”它是什么?”):课程给出的例子是被简化过的
List.addAll:requires写明”两个参数不是同一个对象”,effects写明”把list2的元素追加到list1末尾,并返回list1是否因此改变”。后置条件因此有两条约束:如何修改list1,以及返回值如何确定。 - 关键规则与最佳实践:
- 前置条件可以合法地禁止别名(
list1 != list2):它几乎不排除有用的用法,却让实现变得更简单——可以”从list2取一个元素追加到list1,再取下一个”,直到取完。 - 如果两个参数是同一个列表,这个简单算法不会终止(实际中会因为对象膨胀耗尽内存而崩溃);无限循环与崩溃都被规格允许,因为前置条件已被违反。
- 后置条件必须写清是”修改参数”还是”返回新对象”:
toLowerCase(list)的规格应当说明”返回一个新列表t,长度相同且t[i] = list[i].toLowerCase()“,而不修改list。 - 与 null 同理:除非规格说明写明,默认不允许修改输入对象。规格可以显式写”不修改输入”,但在缺少关于修改的后置条件时,我们一律要求不修改输入。
- 别忘了隐式前置条件(
list1、list2必须非 null),以及”取新变量而不是复用参数”的写法在实现层面的配合。
- 前置条件可以合法地禁止别名(
为什么规格说明有帮助(Why Specifications Help)
- 定义与目的:把前面所有概念收束到三大目标上。
- 直观解释(”它是什么?”):程序中最难缠的 bug 往往源于”接口处行为理解的错位”。每位程序员心里都有规格,但不写下来就意味着团队里存在多份互相冲突的规格——程序失败时,没人说得清该改哪里。
- 关键规则与最佳实践:
- Safe from bugs:好规格清晰记录客户与实现共同依赖的相互假设;用机器可检查的语言特性(静态类型、异常)替代纯注释,能进一步减少 bug。
- Easy to understand:简短简单的规格比实现本身更容易理解,省去别人读代码的功夫——请把
find的一行规格与它的双端搜索实现对比一下。 - Ready for change:规格在代码的不同部分之间建立契约,只要各自继续满足契约要求,就能独立变化。
- 规格是责任委派的前提:没有规格,就无法把”实现这个方法”的责任交出去。
- 规格也是沟通效率的工具:它把”问你打算怎么用”与”读你的源码”两种昂贵沟通方式,替换成一次阅读。
代码示例与对比分析
场景 1:没有规格说明就更换实现——”优化”变成 bug
❌ 错误代码
// 错误:没有写下任何规格,客户开始依赖实现中"恰好"出现的行为
public static int find(int[] arr, int val) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == val) return i;
}
return -1;
}
// 客户代码依赖了"返回最小下标"这一从未写下的行为:
int i = find(scores, 7); // scores = {7, 7, 7}
assert i == 0 : "expected the first occurrence";
// 现在作者为了性能,把它换成"从两端同时向中间扫描"的实现:
public static int find(int[] arr, int val) {
for (int i = 0, j = arr.length - 1; i <= j; i++, j--) {
if (arr[i] == val) return i;
if (arr[j] == val) return j; // 可能返回较大的下标!
}
return -1;
}
// 客户的断言开始随机失败;更糟的是,若客户代码只在某些输入下才检查下标,
// 这个 bug 会在上线后以"偶发错误答案"的形式出现。
【错误代码的问题】
- 缺失契约:两个实现的行为确实不同(重复元素时返回的下标可能不同),但没有任何文档说明客户可以依赖什么,”能不能替换”只能靠运气。
- 客户依赖了未承诺的行为:
assert i == 0依赖”返回最小下标”,这从来不是契约的一部分;一旦实现变化,客户就崩溃。 - 失败模式最糟:错误只在”
val出现多次”的输入下出现,属于”无异常、错误答案”,正是 Reading 1(Static Checking)里失败得最慢、最难定位的一类。 - 作者失去了替换实现的自由:因为行为没有被约定,任何优化都要担心是否踩到某个客户的隐含假设——这正是”没有规格反而更不自由”的悖论。
✅ 正确代码
/**
* Find a value in an array.
* @param arr array to search; requires val occurs exactly once in arr
* @param val value to search for
* @return index i such that arr[i] == val
*/
public static int find(final int[] arr, final int val) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == val) return i;
}
return -1; // 在前置条件成立时永远不会执行到这里
}
// 优化后的实现同样满足这份规格:客户完全不必改动,因为契约没有变化
/**
* Same specification as above: returns index i such that arr[i] == val.
*/
public static int findFromBothEnds(final int[] arr, final int val) {
for (int i = 0, j = arr.length - 1; i <= j; i++, j--) {
if (arr[i] == val) return i;
if (arr[j] == val) return j;
}
return -1;
}
【为什么这样更好】 规格说明把”客户可以依赖什么”钉死在”存在某个下标 i 满足 arr[i] == val“上。在这一契约下,两个实现行为等价,可以自由替换;客户因此也不会写出 assert i == 0 这种越界依赖。注意规格中的 return -1 从未被提及——因为在前置条件成立时它不可达。这也回答了一个常见疑惑:”实现里有 return -1,规格为什么不说?”答案是:规格只需描述合法调用下的行为。
【代码对比解说】 这份规格做了两件事:用前置条件(val 恰好出现一次)排除了”重复元素”这一分歧来源,用后置条件(存在性而非最小下标)明确了客户能依赖的全部内容。它同时是”更弱的后置条件”换”更强的实现自由”的范例:如果作者把后置条件写成”返回最小下标”,客户能依赖更多,但节省的那次扫描就不能省了——规格的每一分强度都有代价。这也是 Reading 7(Designing Specifications)将深入讨论的设计权衡,本讲只需记住:规格的存在使”替换实现”从猜测变成推理。
【设计原则透视】 这组对比直接对应”行为等价”与”规格决定客户能依赖什么”两个概念,并落到 Reading 3(Testing)的规则上:测试断言必须来自规格,assert i == 0 违反规格(假定过多),assert arr[i] == val 才合法。它同时展示了 Reading 4(Code Review)中”注释该写什么”的答案:最重要的注释就是规格说明。
场景 2:用魔法值表示失败 vs 用 Optional / 异常表达可预期失败
❌ 错误代码
// 错误:用 -1 与 null 表示"失败",客户很难不忘记检查,且语义模糊
public static int integerSquareRoot(int x) {
if (x < 0) return -1; // -1 到底是"参数非法"还是"不是完全平方数"?
for (int r = 0; r * r <= x; ++r) {
if (r * r == x) return r;
}
return -1; // 两种完全不同的失败共用同一个魔法值
}
/** @return the birthday of name, or null if not found */
public static LocalDate lookup(String name) {
return book.get(name); // 未找到与"值本身是 null"无法区分
}
// 客户代码
int root = integerSquareRoot(n);
int twice = root * 2; // 忘记检查 -1:得到 -2,错误悄悄传播
LocalDate bd = lookup("Alyssa");
System.out.println(bd.getMonth()); // 忘记检查 null:运行时 NullPointerException
【错误代码的问题】
- 语义模糊:
-1同时表示”参数非法”和”不是完全平方数”,客户无法区分;Map.get返回null也既可能是”值是 null”,也可能是”键不存在”。 - 极易漏检:返回值检查是一种约定,编译器不会提醒;忘记检查就得到错误答案(
-2)或运行时异常,而且错误位置离成因很远。 - 规格不完整:
lookup的 Javadoc 只写了”或 null”,既没有写前置条件,也没有说明”未找到”是不是一种正常的、需要处理的失败。 - 无法区分该处理与不该处理:参数非法属于客户的 bug(应当快速失败),”不是完全平方数”属于可预期的失败(应当可以被捕获或显式处理),把它们混成一个值就丧失了这种区分能力。
✅ 正确代码
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
/** name to birthday; contains no null keys and no null values */
private static final Map<String, LocalDate> book = new HashMap<>();
/**
* Compute the integer square root.
* @param x integer value to take the square root of; requires x >= 0
* @return the square root of x if x is a perfect square,
* otherwise OptionalInt.empty()
* @throws IllegalArgumentException if x is negative (a client bug)
*/
public static OptionalInt integerSquareRoot(final int x) {
if (x < 0) {
throw new IllegalArgumentException("x must be nonnegative: " + x); // fail fast
}
for (int r = 0; (long) r * r <= x; ++r) {
if (r * r == x) return OptionalInt.of(r);
}
return OptionalInt.empty(); // 可预期的"没有结果",由类型显式表达
}
/**
* Look up a person's birthday.
* @param name the person's name; requires name is not null
* @return the birthday of name, or Optional.empty() if the birthday book
* has no entry for name
*/
public static Optional<LocalDate> lookup(final String name) {
Objects.requireNonNull(name, "name");
return book.containsKey(name) ? Optional.of(book.get(name)) : Optional.empty();
}
// 客户代码:缺失这件事必须被显式处理,语义清楚,无魔法值
OptionalInt root = integerSquareRoot(n);
if (root.isPresent()) {
int twice = root.getAsInt() * 2;
}
Optional<LocalDate> birthday = lookup("Alyssa");
birthday.ifPresent(bd -> System.out.println(bd.getMonth()));
【为什么这样更好】 两种失败被彻底分开:违反前置条件(x < 0)立刻抛异常,属于快速失败;可预期的”没有结果”由返回类型 OptionalInt / Optional<LocalDate> 显式承载,客户必须调用 isPresent() 或 ifPresent() 才能取值,漏检变得困难。规格说明同时说明了前置条件(x >= 0)、后置条件(完全平方数时返回其平方根)与失败语义(否则为空),因此行为等价性可以被判断,客户也知道自己该处理什么。
【代码对比解说】 课程的立场是:异常适合表达”调用者不太可能处理的问题”(例如参数非法,说明调用方或其上游有 bug),而”可预期的失败”应该让客户能方便地处理。Java 用受检异常(编译器强制调用者 catch 或向上声明)来表达后一种情况,这是相对 TypeScript 的一个实质优势;TypeScript 没有异常处理的静态检查,用异常表达可预期失败容易漏掉 try...catch,因此更倾向用联合类型(number \| undefined)这类”特殊结果”。Java 中对应的做法就是 Optional。(补充说明:如果失败确实罕见且客户几乎总是希望处理,也可以选择受检异常 throws NotPerfectSquareException——此时异常必须同时出现在 @throws 与签名 throws 中;两种做法都比魔法值好。)
【设计原则透视】 这一组把”后置条件必须完整描述可能的输出(包括异常)”“Null 默认禁用”“异常用于可预期失败”三条规则绑在一起,并落到 Reading 3(Testing)上:返回 Optional 的规格比返回魔法值的规格更容易写出正确的测试,因为”没有结果”是类型系统可见的分支,测试可以分别覆盖两条路径。它也预告了 Reading 8(Immutability):Optional 与其承载的值都是不可变的,可以安全共享,不存在”谁负责拷贝”的问题。
场景 3:别名与前置条件——addAll 的无限循环
❌ 错误代码
// 错误:实现依赖了"两个列表不是同一个对象"这一前提,却从未写下来,也不检查
public static <T> boolean addAll(List<T> list1, List<T> list2) {
boolean changed = false;
for (T element : list2) {
list1.add(element); // list1 == list2 时:边遍历边追加,永不终止!
changed = true;
}
return changed;
}
【错误代码的问题】
- 未文档化的前置条件:客户完全不知道”不能把列表加到自己身上”,会写出
addAll(list, list)并期待得到”元素翻倍”。 - 灾难性失败模式:
list1 == list2时迭代器永远取得到新元素,程序不终止;实际结果是列表膨胀到耗尽内存而崩溃。规格从未提醒过这种可能。 - 后置条件不完整:也没有说明”当
list2为空时返回false“,客户只能靠猜。 - 客户无法判断对错:因为没有规格,这条失败的调用既不能被称为”客户违规”,也不能被称为”实现有 bug”,追责无从谈起。
✅ 正确代码
/**
* Adds the elements of list2 to the end of list1.
* @param list1 list to be modified; requires list1 is not null, and
* list1 and list2 are not the same object
* @param list2 list whose elements are appended; requires list2 is not null,
* and list2 is not the same object as list1
* @return true if list1 changed as a result of the call
* (false if list2 was empty)
*/
public static <T> boolean addAll(final List<T> list1, final List<T> list2) {
Objects.requireNonNull(list1, "list1");
Objects.requireNonNull(list2, "list2");
if (list1 == list2) {
throw new IllegalArgumentException("cannot add a list to itself");
}
boolean changed = false;
for (T element : list2) { // 前置条件保证 list1 != list2,因此遍历时修改 list1 是安全的
list1.add(element);
changed = true;
}
return changed;
}
【为什么这样更好】 前置条件被显式写下(非 null、且两参数不是同一对象),客户从此知道自己的义务;实现者在入口处用一个便宜的身份比较(list1 == list2)把违反前置条件变成立刻抛出的异常,而不是让程序陷入不可终止的循环——这正是”异常与前置条件的关系”的具体落地:规格可以只写前置条件、把违规行为留作未定义;也可以像这里一样,因为检查成本极低,把违规行为提升为明确的动态错误。后置条件完整描述了两种结果(修改了 list1 与否,以及返回值含义),客户可以放心依赖。
【代码对比解说】 这里体现了规格设计中的一个实用判据:当前置条件可以被廉价地检查时,就把它检查出来并快速失败;当检查代价高昂(例如”val 恰好出现一次”需要先扫一遍数组),就把它留在 requires 里,作为客户的义务。课程原文还展示了另一种等价设计:把前置条件从 requires 中删除,改为在 effects 中用 @throws AliasingError if array1 === array2 描述两种情形下的行为——这样契约对客户更友好(不必担心违规),代价是实现必须处理这种情况。两种写法都正确,选择取决于”谁更适合承担检查成本”。
【设计原则透视】 这组对比把”前置条件/后置条件”“逻辑蕴含”“规格与异常的关系”三条连在一起。它也示范了 Reading 4(Code Review)中的”每个变量一个用途”与”快速失败”如何与规格配合:Objects.requireNonNull 把隐式前置条件变成显式检查。此外,”遍历 list2 同时修改 list1“之所以安全,完全建立在规格的前置条件之上——实现可以依赖前置条件,这正是契约”对实现者也有利”的证明。
场景 4:规格说明谈论实现细节 vs 规格说明只谈论可观察行为
❌ 错误代码
// 错误:规格谈论私有字段、局部变量与内部算法;隐含未声明的义务;用 null 表达"未找到"
public class TextSearch {
private final StringBuilder buf = new StringBuilder();
private boolean initialized = false;
private static final int MAX_LEN = 100;
public void init() {
initialized = true;
}
/**
* Uses a StringBuilder held in the private field buf, plus a local counter i;
* if the loop falls through it returns -1. Assumes the caller has already
* called init() and that MAX_LEN is at least 1.
*/
public int indexOf(final String text, final char ch) {
buf.setLength(0);
buf.append(text);
for (int i = 0; i < Math.min(buf.length(), MAX_LEN); i++) {
if (buf.charAt(i) == ch) {
return i;
}
}
return -1;
}
/**
* @param text the text; if text is null we return null instead of throwing
* @return boxed result of indexOf(text, ch)
*/
public Integer indexOfOrNull(final String text, final char ch) {
if (text == null) {
return null; // 把"参数违规"和"未找到"混在一起,且未声明前置条件
}
return indexOf(text, ch);
}
}
【错误代码的问题】
- 规格谈论私有字段与局部变量(
buf、i、MAX_LEN),把实现变成了契约的一部分:任何内部重构都会让规格变成谎言,客户也可能开始依赖这些内部结构。 - 隐含未声明的义务(”调用者必须先调用
init()“)藏在文档里却不在前置条件中,客户很难发现,违反后表现为神秘崩溃。 - 用 null 表达”未找到”(
if text is null we return null)把两种完全不同的事情——参数违规与正常缺失——混为一谈,且默认违反了”null 必须显式声明”的约定(它声明了,但这是一个坏设计)。 - 返回值定义成”局部变量的值”,客户完全无法据其编程;规格实际上没有提供任何可依赖的信息。
✅ 正确代码
/**
* Find the first occurrence of a character in a string.
* @param text the string to search; requires text is not null
* @param ch the character to search for
* @return the lowest index i such that text.charAt(i) == ch,
* or -1 if ch does not occur in text
*/
public int indexOf(final String text, final char ch) {
for (int i = 0; i < text.length(); i++) {
if (text.charAt(i) == ch) {
return i;
}
}
return -1;
}
/**
* Find the first occurrence of a character in a string.
* @param text the string to search; requires text is not null
* @param ch the character to search for
* @return an index i such that text.charAt(i) == ch, or
* OptionalInt.empty() if ch does not occur in text
*/
public OptionalInt indexOfOrEmpty(final String text, final char ch) {
int i = indexOf(text, ch);
return i < 0 ? OptionalInt.empty() : OptionalInt.of(i);
}
【为什么这样更好】 规格只谈论参数、返回值与可预期的失败,客户读完就能正确使用,完全不需要知道内部用了什么数据结构;前置条件(text 非 null)被显式写出;”未找到”的语义由返回类型与文档共同固定(-1 或 OptionalInt.empty()),不再与”参数非法”混淆。规格稳定,因此实现可以自由演化。
【代码对比解说】 判断规格是否越界有个简单测试:如果我把实现整个重写(换数据结构、换算法、改字段名),这份规格是否仍然完全正确? 若答案是”否”,规格就写到了防火墙的错误一侧。同时注意”规格该说多少细节”的度:说少了客户无法编程(如”返回某个值”),说多了实现无法演化(如规定”返回最小下标”)。课程的判据是把客户真正需要依赖的东西写足,其余留白。
【设计原则透视】 这条规则是抽象屏障的直接体现,与 Reading 11(Abstraction Functions & Rep Invariants)互为表里:规格说明规定”外部可见行为”,表示不变量规定”内部表示必须满足的约束”,两者结合才能保证”实现可以自由更换而客户不受影响”。它也解释了为什么”在规格里写算法”是坏味道——那等于把 Reading 4 中”为变更而设计”的成果提前抵押掉。
场景 5:违反规格的测试——玻璃盒测试也不能越界
❌ 错误代码
// 错误:测试依赖了规格从未承诺的行为,并且调用了违反前置条件的输入
@Test public void testFindBad() {
int[] array = { 7, 7, 7 };
int i = find(array, 7);
assertEquals(0, i); // 规格只说"返回某个 i",没承诺最小下标
// 前置条件要求 val 在 arr 中出现;空数组违反前置条件,其行为未定义
assertThrows(ArrayIndexOutOfBoundsException.class, () -> find(new int[0], 7));
}
【错误代码的问题】
- 断言超出契约:
assertEquals(0, i)假定实现总是返回最小下标——如果实现优化成”从两端扫描”,这个测试会因为一个并不违反契约的改动而失败,测试成了重构的阻力。 - 测试违反前置条件:空数组上调用
find超出了规格定义范围,其行为是实现自由(-1、异常、任意值都合法),因此任何关于它的断言都没有依据;这类测试还会把”未定义行为”固化成客户可依赖的事实,破坏契约。 - 测试与规格脱节:测试本应验证”实现是否满足规格”,这份测试却在验证”实现是否等同于当前这种写法”,失去了发现真正 bug 的能力。
- 单元测试失去聚焦:它把”实现细节”与”契约行为”混在一处,失败时无法判断是规格变了、实现变了,还是测试写错了。
✅ 正确代码
// 正确:只断言规格承诺的性质,用例选取遵循前置条件
@Test public void testFindHonorsSpec() {
int[] array = { 7, 7, 7 };
int i = find(array, 7); // 前置条件满足:7 在 array 中出现
assertTrue(0 <= i && i < array.length, "returned index must be a valid index");
assertEquals(7, array[i], "arr[i] must equal val"); // 这正是后置条件
int[] single = { 42 };
assertEquals(0, find(single, 42)); // 在"恰好出现一次"时,下标是唯一确定的
}
// 需要更强行为时,先改规格,再改实现与测试
/**
* @param arr array to search; requires val occurs in arr
* @return the LOWEST index i such that arr[i] == val
*/
public static int findFirst(final int[] arr, final int val) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == val) {
return i; // 从左向右扫描,因此第一个命中的就是最低下标
}
}
return -1; // 前置条件成立时不可达
}
【为什么这样更好】 断言直接来自后置条件——”存在下标 i 使得 arr[i] == val“,因此它对任何满足规格的实现都成立,不会成为重构的阻力;测试用例只使用满足前置条件的输入,因此从不依赖未定义行为;如果某天客户真的需要”最小下标”这一更强保证,正确做法是先加强规格(如 findFirst 的 @return the LOWEST index),再让实现与测试跟上——契约是唯一的行为权威。
【代码对比解说】 玻璃盒测试的意义在于”用了解实现的知识去挑选用例(覆盖不同代码路径)”,而不是”用了解实现的知识去写断言“。例如知道实现是双端扫描,就应该分别构造”匹配元素在左半”与”在右半”的用例,但两者都只断言 arr[i] == val。同理,extract() 的测试不应依赖 load() 的行为(Reading 3 Testing 中的搜索引擎例子):单元测试应只依赖被测方法的规格与标准库的规格。
【设计原则透视】 这一组把规格说明与测试的关系讲透:规格是测试的预期来源,测试是规格的执行证据。它也与 Reading 4(Code Review)呼应——审查者看到 assertEquals(0, find(array, 7)) 时,第一反应应当是”规格承诺了最小下标吗?”如果没有,这条断言就是隐藏的技术债。
场景 6:Null 的静默传播 vs 显式的非空契约
❌ 错误代码
// 错误:接受 null 并静默地把它传播下去,把错误推迟到很远的地方
public static List<String> toLowerCase(List<String> list) {
if (list == null) {
return null; // 静默传播:调用者迟早会遇到 NPE
}
List<String> out = new ArrayList<>();
for (String s : list) {
out.add(s == null ? null : s.toLowerCase()); // 元素里的 null 被悄悄保留
}
return out;
}
// 客户代码
List<String> lower = toLowerCase(names);
System.out.println(lower.get(0).length()); // 可能在完全无关的地方抛 NullPointerException
【错误代码的问题】
- 静默传播:
null被当作合法输入接受又被当作合法输出返回,错误信息的”距离成因”被拉得很远,调试成本极高。 - 规格缺失:既没有写”参数不得为 null”,也没有写”返回的列表不含 null 元素”,客户无从知道自己的义务与可依赖的保证。
- 违反”避免 null”的默认约定:按照课程约定,除非规格明确声明,参数与返回值(包括集合元素)都不允许 null;这段代码既没有声明,也没有遵守。
- 掩盖了两种不同的错误:参数是
null(客户 bug)与元素是null(数据问题)应当以不同方式被暴露,这里两者都被无声吞下。
✅ 正确代码
import java.util.Objects;
/**
* Convert every string in a list to lower case.
* @param list list of strings to convert; requires list is not null and
* contains no null elements
* @return a new list t, same length as list, where t.get(i) is
* list.get(i).toLowerCase() for all valid indices i;
* list itself is not modified
*/
public static List<String> toLowerCase(final List<String> list) {
Objects.requireNonNull(list, "list"); // 违反前置条件 → 立刻失败
List<String> result = new ArrayList<>(list.size());
for (String s : list) {
result.add(Objects.requireNonNull(s, "element of list").toLowerCase());
}
return result;
}
【为什么这样更好】 隐式前置条件(非 null、元素非 null)被写进 @param 并用 Objects.requireNonNull 快速失败,客户在第一次调用时就会看到清晰的错误信息,而不是在几百行之后遇到 NullPointerException;后置条件明确写出”返回新列表、长度相同、逐元素小写、且不修改输入”,客户可以放心依赖;”不修改输入”这一条不再需要猜测,因为缺少关于修改的后置条件时默认就是不允许修改。
【代码对比解说】 Java 没有 TypeScript 那样的严格空检查(strictNullChecks),因此约定 + 运行时检查 + 注解三者合起来承担同样的职责:约定规定”默认非 null”,Objects.requireNonNull 提供廉价的运行时快速失败,@NonNull/@Nullable 注解(补充说明)让静态分析工具能在编译期给出提示。当确实需要表达”值可能缺失”时,使用 Optional<T> 而不是 null,并只在规格确实允许缺失的地方使用——这正是”克制地使用 Optional”的含义。
【设计原则透视】 这一组把 “Avoid null” 与 “Include emptiness” 两个概念联结起来:空列表、空字符串是完全合法的输入与返回(除非规格禁止),因此 toLowerCase(new ArrayList<>()) 应当返回空列表而不是抛异常或返回 null;而 null 则是需要显式声明的例外。这也呼应 Reading 8(Immutability):返回新对象而不是修改输入,使方法的行为更可预测,也让不可变值可以被安全共享。
与其他设计原则的关联
- 与 Reading 1(Static Checking):签名本身就是规格中”由编译器自动检查”的部分——参数类型、返回类型、受检异常的
throws声明。规格说明则补齐编译器无法检查的那部分,两级检查共同构成”尽早失败”的体系。 - 与 Reading 3(Testing):测试必须依据规格编写,黑盒测试只用规格挑选用例,玻璃盒测试用实现知识挑选用例但仍只用规格写断言;单元测试聚焦单一规格,集成测试检验规格之间是否兼容。
- 与 Reading 4(Code Review):本讲回答”注释该写什么”——最重要的注释就是规格说明;上一讲的”避免魔法数字”“返回结果而不是打印”“避免特例代码”在接口层面表现为”不要用
-1/null表示失败”“后置条件要说明返回值”。 - 与 Reading 7(Designing Specifications):本讲建立了前置/后置条件与强弱判据,下一讲将系统讨论如何设计规格:前置条件该不该检查、如何决定规格的强弱、确定性(determinism)与欠定性(underdetermination)等。
- 与 Reading 8(Immutability):规格默认要求”不修改输入”,这与不可变对象的设计互为支撑;
Optional与不可变值可以安全共享,无需防御性拷贝。 - 与 Reading 11(Abstraction Functions & Rep Invariants):规格说明是抽象边界的外侧(客户可见的行为),表示不变量与抽象函数是内侧(实现必须维持的性质);两者共同保证”实现可以自由更换”。
- 与 Reading 21–23(Concurrency / Locks):并发方法必须在线程安全的规格中写明(例如”this method is thread-safe”或”requires no other thread is mutating this object”),否则客户无法判断能否并发调用——规格说明是并发契约的唯一载体。
- 与 Reading 29(Team Version Control):规格说明是团队分解工作的前提:只有接口契约稳定,不同成员才能并行实现与修改不同模块。
关键要点
- 规格是一份双方契约:客户承担前置条件,实现者承担后置条件;整体是
pre → post的逻辑蕴含,前置条件不成立时实现完全自由(可以返回任意值、抛任意异常、做任意修改、甚至不返回)。 - 规格说明决定可替换性与可依赖范围:只有当规格明确写出客户可以依赖什么,两个实现才可能”行为等价”;客户不能依赖规格之外的行为,测试也不能断言规格之外的结论。
- 写规格只谈可观察行为:参数、返回值、异常、对参数的修改;绝不谈局部变量、私有字段与算法。判据是”重写实现后这份规格是否仍然正确”。
- 默认规则要记住三条:参数与返回值(含集合元素)默认非 null;空值(空串、空列表)默认合法;除非写明修改,否则不得修改输入。
- 异常分两类,写法不同:信号 bug 的异常(
NullPointerException、IndexOutOfBoundsException)绝不写进规格;信号可预期失败的异常必须写@throws,在 Java 中受检异常还要写进签名throws。当失败难以被调用者处理时用异常,当失败是调用者必须处理的常见分支时用Optional这类显式结果类型。
常见陷阱与注意事项
- 用
-1、null或false表示失败 → 失败变成一个看起来合法的值,客户容易漏检,错误静默传播到很远的地方;语义也模糊(null无法区分”值就是 null”与”键不存在”)。补救:用异常表达”调用者难以处理的问题”,用Optional/联合类型表达”可预期的缺失”。 - 在规格里描述实现(提及私有字段、局部变量、具体算法) → 规格与实现绑死,任何内部重构都要同步改文档,客户还可能依赖内部细节;抽象屏障被击穿。补救:只写参数、返回值与副作用,并用”重写实现后规格是否仍正确”来检验。
- 测试依赖规格未承诺的行为,或调用违反前置条件的输入 → 测试变成重构的阻力,还会把未定义行为固化成”事实”;真正的 bug 反而测不出来。补救:断言只来自后置条件,用例只使用满足前置条件的输入;需要更强保证时先改规格。
- 忘记隐式前置条件与默认不修改约定 → 方法在
null上神秘崩溃,或悄悄修改了客户传入的集合(客户以为它是只读的),引发极难复现的 bug。补救:在入口用Objects.requireNonNull快速失败,并在@param中写明非空要求;后置条件中要么声明修改,要么保证返回新对象。 - 把”信号 bug 的异常”写进
@throws,或让未受检异常出现在throws子句里 → 前者让规格充满噪声(客户根本无法、也不应该处理这类异常),后者误导读者以为它是受检异常。补救:只把可预期失败写入规格;受检异常才写进签名。 - 用
catch (Exception e) { return; }让编译器闭嘴 → 连IndexOutOfBoundsException这类真正的 bug 都被静默吞掉,程序表现出难以理解的行为,且没有任何堆栈线索。补救:捕获最具体的异常类,捕获块要尽量窄;需要换层语义时做异常转译(如PathNotFoundException→RobotStuckException)。
思考题(带答案)
问题 1:下面这份规格是否允许实现在”arr 为空”时抛异常?为什么?如果你希望客户能够依赖”空数组时返回 -1”,应该怎样修改规格?
/**
* Find a value in an array.
* @param arr array to search; requires val occurs exactly once in arr
* @param val value to search for
* @return index i such that arr[i] == val
*/
public static int find(int[] arr, int val)
答案:允许,而且实现可以做任何事。规格的前置条件是”val 在 arr 中恰好出现一次”;当 arr 为空时该前置条件必然不成立,于是实现不受后置条件约束——抛出规格未提及的异常、返回任意值、修改 arr 都是合法的。这解释了为什么规格完全不必提及实现中那句 return -1:在前置条件成立时它不可达,规格只描述合法调用下的行为。如果希望客户能依赖”空数组时返回 -1”,就必须把规格改强:删掉”val 恰好出现一次”这一前置条件(或改弱为”arr 非 null”),并在后置条件中写明”若 val 不在 arr 中则返回 -1”。注意这是”前置条件更弱 + 后置条件更强”的组合,因此新规格比原规格更强,实现者的负担更重,但客户获得了更多可依赖的保证。
问题 2:countLongWords 的两个版本——一个返回 int 并把最长单词写进全局变量,另一个返回一个不可变的小对象——哪一个更容易写出好的规格说明?请从”规格可以谈论什么”与”后置条件的完整性”两个角度解释。
答案:第二个版本容易得多。第一,规格只能谈论参数、返回值与副作用;第一版的第二个结果藏在全局变量里,规格根本无法用”返回值”的方式描述它,只能写”同时把 longestWord 全局变量设为……”,这既依赖了实现细节(全局状态),又让客户必须知道该去看哪个名字。第二,第一版还有”打印到控制台”这一副作用,规格必须额外描述它的输出格式,而后置条件本应描述状态而非人类可见的输出。第二版把两个结果打包进一个不可变的返回值对象,后置条件可以完整写成”返回一个 WordStats,其中 count() 是长度超过 LONG_WORD_LENGTH 的单词数,longestWord() 是最长单词(无单词时为 "")”,客户一读即懂,实现也能自由更换内部算法。这也印证了 Reading 4 的”函数应返回结果而不是打印”——那条规则的可维护性收益,本质上来自它让规格变得可写。
问题 3:某团队规定”任何异常都必须写进 @throws“,于是 find 的规格变成了:
/**
* @throws NullPointerException if arr or val is null
* @throws ArrayIndexOutOfBoundsException if the implementation has an index bug
* @throws OutOfMemoryError if the array is enormous
* @return index i such that arr[i] == val; requires val occurs exactly once in arr
*/
请评价这份规格,并说明”异常与前置条件的关系”应当如何正确理解。
答案:这份规格是错误的。NullPointerException 属于”信号 bug 的异常”:参数非 null 已经是隐式前置条件,实现可以在客户违反时自由抛出它,因而不属于后置条件,不该出现在 @throws 中;把它写出来反而会让读者以为需要处理它。ArrayIndexOutOfBoundsException 是实现的 bug,它根本不该发生,写进规格等于把缺陷当成契约的一部分。OutOfMemoryError 属于 Error 家族,是 JVM 层面的资源失败,同样不应作为方法契约的一部分(Java 约定:不要继承 Error,也不要把它当作正常的失败通道)。正确的理解是:异常只有两种合法角色——一是把”前置条件被违反”变成可诊断的失败(可写可不写,通常只需隐含在前置条件里;受检异常才必须写进签名),二是表达可预期的失败并让客户能够响应(必须写进 @throws,受检异常还要写进 throws)。因此 integerSquareRoot 的规格应当写 @throws NotPerfectSquareException if x is not a perfect square 并用 OptionalInt 或受检异常承载”缺失”,而不是罗列一堆 bug 型异常。
