Reading 11: 抽象函数与表示不变量(Abstraction Functions & Rep Invariants)
Reading 11: 抽象函数与表示不变量(Abstraction Functions & Rep Invariants)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲要回答一个此前一直被绕开的问题:当我们说一个类「实现」了某个抽象数据类型(abstract data type, ADT)时,究竟是什么意思?答案由两个数学对象给出:抽象函数(abstraction function, AF) 把每个合法的表示值映射到它所代表的抽象值,表示不变量(representation invariant, RI) 划出哪些表示值是合法的表示值;再补上一条纪律——绝不暴露表示(rep exposure)——三者合起来才使一个 ADT 真正「守护自己的不变量」。这三个概念是整个 6.031 最实用的一副理论工具:它让你在 Reading 15(相等性)里用抽象值而非表示值定义 equals(),让你在 Reading 13(调试)里把缺陷隔离在类内部。它与三大目标的关系是:Safe from bugs(不变量被 checkRep() 在运行时断言,数据结构的损坏当场暴露,而不是继续传播)、Easy to understand(AF/RI 注释把「表示如何被解释」写成可读的事实)、Ready for change(抽象与表示分离,替换表示不需要动任何客户端代码)。
核心概念与设计原则详解
不变量(Invariant)
- 定义与目的:不变量是程序的一个性质,在程序的每一个可能的运行时状态下都成立。它是「让代码可以被推理」的基本单位:只要你能依赖某个不变量,就不必再去检查与之矛盾的可能性。好的 ADT 之所以有价值,最核心的一条就是它会保持自己的不变量。
- 直观解释(”它是什么?”):想象一间图书馆,只要规则「书永远按索书号排列」始终为真,你就可以闭着眼睛用二分查找找书;一旦这条规则偶尔被打破,你就必须每次从头扫描。不变量就是这种「可以放心依赖的事实」。对对象而言,「始终为真」精确地收缩为该对象的整个生命周期:从构造完成的那一刻起,直到它被回收。不变量的例子包括:变量的类型(
int i意味着i永远是整数)、变量之间的关系(用一个下标i遍历数组时,循环体内0 <= i && i < a.length是不变量)、以及我们已经见过的不可变性(不可变对象一旦创建,永远表示同一个值)。 - 关键规则与最佳实践:
- 把不变量所涉及的变量隐藏或保护起来:用
private修饰字段,只通过具有明确契约的操作访问——「守护自己的不变量」意味着责任在 ADT 自己身上,而不在客户端。 - 优先使用能写下强不变量的类型:
String不可变,因此任何持有String的 ADT 都不必担心它被外人改掉;而想象一个可变的字符串类型,任何拿到它引用的代码都能修改它,于是「推理」退化为「必须检查程序中所有可能接触到它的地方」。 - 不变量必须被确立,也必须被保持:只要有一个操作破坏它,整座大厦就塌了,所以每个构造者/生产者/修改者都要对不变量负责。
- 把不变量写成可执行的断言,而不只是注释;注释会过期,
assert会当场喊出来。 - 注意:禁止
null是默认约定——RI 隐含地对表示中每个对象引用x要求x != null,因此不必在 RI 注释里重复写。
- 把不变量所涉及的变量隐藏或保护起来:用
表示不变量(Representation Invariant, RI)
- 定义与目的:RI 是一个从表示值到布尔值的函数
RI : R → boolean。RI(r)为真,当且仅当r是一个合法(良构,well-formed)的表示值,也即r位于抽象函数有定义的范围之内。等价的说法是:把 RI 看作表示值空间的一个子集——那些能映射到抽象值的表示值构成的子集。它的作用是把「内部状态在什么条件下才有意义」这一隐含知识显式化,并让损坏的数据结构尽早被抓住。 - 直观解释(”它是什么?”):RI 就是这份表示空间的「入场券」规则,或者说表示值空间里的绿灯区与红灯区。以用字符串表示字符集合
CharSet为例,若规定字符串中不得出现重复字符,则RI("a") = true、RI("ac") = true、RI("acb") = true,而RI("aa") = false、RI("abbc") = false。绿灯区里的表示值一定映射到某个抽象值;红灯区里的表示值没有对应的抽象值,因为它们根本不是合法状态。这条「不许重复」的规则并非无用的洁癖:它让remove在遇到该字符的第一个实例时就可以收工,因为至多只有一个。 - 关键规则与最佳实践:
- RI 是「字段值的合法条件」,不是「抽象值的性质」:作为函数,把实际的字段值(合法或非法都行)代入文档化的 RI,必须得到一个布尔值。一旦你在 RI 里提到抽象值,就是本末倒置了——非法的表示值根本没有抽象值,RI 必须只谈表示本身。
- RI 不能是空泛的话:像「所有字段都有效」这样的 RI 毫无价值;RI 的职责是精确说明什么样的字段值组合是合法的。
- 同一个表示值空间可以有不同 RI:同样用
String表示字符集合,可以选择「无重复」的 RI,也可以选择「字符按非降序排列(允许重复)」的 RI,后者允许对字符串做二分查找,把contains从线性时间降到对数时间。表示值的类型选择决定不了 RI,这是 AF/RI 不是「冗余信息」的关键理由之一。 - RI 写在表示(私有字段)声明的旁边,用普通注释而不是 Javadoc 注释——写成 Javadoc 就等于把它当成公开规格的一部分,会破坏表示独立性与信息隐藏。
- 让
checkRep()与 RI 一一对应:RI 写了什么,checkRep()就该断言什么(包括隐含的!= null)。
抽象函数(Abstraction Function, AF)
- 定义与目的:AF 是一个从表示值到抽象值的映射
AF : R → A,说明「这堆具体字段值,被解释成哪个抽象值」。它使你能够在纸面上、也在代码里精确定义这个类型是什么,而不是它是怎么存的。AF 是 Reading 15 中为不可变类型定义equals()的基础,也是理解「表示独立」的钥匙。 - 直观解释(”它是什么?”):把表示值想成密码本里的一行密文,AF 就是解码规则。以
CharSet中AF(s) = {s[i] \| 0 <= i < s.length()}为例,代入一个合法表示值s="abbc"得到AF("abbc") = { "abbc"[i] \| 0 <= i < "abbc".length() } = {'a','b','c'}——注意右边真的随着代入而计算出了唯一的结果。反例是含糊的 AF:AF(s) = 一个字符集合,代入s后右边毫无变化,这种 AF 完全没有说明"abbc"到底代表哪个集合,等于没写。 - 关键规则与最佳实践:
- AF 只对合法表示值有定义:
RI(r)为真 ⟺r被 AF 映射;非法表示值不在 AF 的定义域内。 - AF 描述「表示值 → 抽象值」,且必须能代入求值:AF 的写法应当像数学函数,等号右边要出现字段名。
- AF 不是由两个值空间唯一决定的:同一个抽象值空间可以有多种表示(字符集合既可以用字符串表示,也可以用一个位向量表示,每个可能字符占一位——显然需要两个不同的 AF);即使表示值空间相同、RI 也相同,仍可以有不同 AF。例如对同样的「任意字符串」RI,我们可以把字符串解释为集合的元素,也可以把相邻字符两两成对解释为区间:
"acgg"被解释为[a-c]与[g-g]两个区间,代表集合{a,b,c,g},此时 RI 变为「s.length()是偶数,且字符非降序」。 - 实现一个抽象类型,意味着三件事都要选:抽象值空间(规格用)、表示值空间(实现用)、哪些表示值合法(RI)以及如何解释它们(AF)。
- 把 AF/RI 写进代码:如果不同的实现者对表示的含义有分歧,这份表示就不再可靠;写下来是唯一可靠的沟通方式。
- AF 只对合法表示值有定义:
满射但不必单射(Surjective, Not Necessarily Injective)
- 定义与目的:用函数的术语精确描述 AF 的性质:它是满射(surjective,也称 onto)——每个抽象值都被某个表示值映射到;不一定单射(injective,一对一),因此不一定双射(bijective);并且常常是部分函数(partial)——不是所有表示值都被映射。这个性质解释了「同一抽象值可以有多种表示」,而这正是让
equals()必须比较抽象值的根源。 - 直观解释(”它是什么?”):满射意味着「你想造的任何抽象值,我都造得出来」——实现抽象类型的目的就是支持对抽象值的操作,所以所有抽象值都必须可表示。不满射的实现等于有些合法值永远造不出来,是设计缺陷。不单射意味着「多个密码可以解出同一段明文」,例如把无序字符集合存成字符串时,
"abc"、"bca"、"cab"都可以表示同一个集合{a,b,c};这就是「编码不紧致(not a tight encoding)」。部分函数意味着「有些密码是废码」,例如"abbc"在「不许重复」的 RI 下没有意义。用有理数RatNum看得更清楚:在「分母为正且已约分」的 RI 下,(1,2)是合法表示,(2,4)落在红灯区(可用但被 RI 禁止),而(4,2)与(2,1)都映射到整数 2——不单射带来的「同一抽象值、多种表示」正是2/4与1/2这类相等性问题的根源。本讲原文用CharSet演示了 AF 的这两条要求:AF(s) = {s[i] \| 0 <= i < s.length()}是一个合格的 AF,因为代入合法表示值s = "abbc"可以直接算出AF("abbc") = {"abbc"[i] \| 0 <= i < "abbc".length()} = {'a','b','c'};而含糊的AF(s) = a set of characters代入s后右边毫无变化,完全说不出"abbc"到底对应哪个集合,因此毫无信息量。(Duration(1,2)与Duration(0,62)是同一现象的另一个例子,该示例出自 Reading 15(相等性),此处借用说明同一概念。) - 关键规则与最佳实践:
- 检查满射性:每个抽象值都必须有表示值映到它,否则存在客户端「要不到」的合法值。
- 接受不单射,但要一致地处理它:既然一个抽象值可以有多个表示,任何「比较两个对象」的操作都必须比较抽象值,绝不能比较字段。
- 窄化 RI 会改变合法表示集合、影响性能取舍:
RatNum若采用更强的 RI(要求约分到最简),每次运算都要做gcd;若采用更弱的 RI(只要求分母非零),连续运算可以免去约分,直到需要显示结果时再化简——两种设计取舍不同,但抽象值空间完全相同。 - 让 AF 保持「可判定的解释」:AF 必须能机械地代入求值;不要写需要「意会」的 AF。
- 如果 RI 太严,考虑它是否真的必要:过强的 RI 会让实现处处受限,过弱的 RI 会让不变量失去保护力,选择 RI 是一种设计权衡。
表示暴露(Representation Exposure)与防御性拷贝(Defensive Copying)
- 定义与目的:表示暴露指类外部的代码能够直接修改(或至少直接取得)类的表示。它同时威胁两件事:不变量(外部代码可以在 ADT 的任何一个操作之外改坏内部状态)和表示独立性(客户端会依赖具体的表示,于是你再也换不动实现)。防御性拷贝是修补手段:拷贝一份可变对象,避免把表示中的引用泄漏出去。
- 直观解释(”它是什么?”):别名(aliasing)是核心机制:当两个变量指向同一个可变对象时,通过其中一个改,另一个也变了。
Tweet的例子最能说明问题——getTimestamp()返回的Date与t.timestamp是同一个对象,于是这段「完全合理」的客户端代码Date d = t.getTimestamp(); d.setHours(d.getHours()+1);顺手把t里的时间也改了,Tweet的不可变性轰然倒塌。反方向的泄漏同样是 bug:Tweet的构造器直接把传入的Date存进表示,于是下面这段想造 24 条推文的循环,因为反复修改同一个Date对象,最终 24 个Tweet的时间戳全都一样。要养成一个习惯:审视所有操作的参数类型与返回类型,只要其中有可变类型,就确认实现既没有把参数直接存进表示,也没有返回表示内部的直接引用。 - 直观解释续(为什么不用规格来免责):可以写「调用者此后绝不能再修改这个
Date对象!」,在某些别无选择时(例如可变对象太大,拷贝代价高)确实会这么做,但它对「推理程序」和「避免 bug」的代价极大。除非有压倒性的理由,值得让 ADT 自己保证不变量,而杜绝表示暴露是其中的必要条件。 - 本讲原文给出的三个代表性反例:
RightTriangle(表示暴露的经典反例):它的表示是private double[] sides;外加一个公开字段public final double hypotenuse;。getAllSides()的实现是return sides;——直接把内部数组交给客户端,于是客户端可以随意改写边长(/*E*/处的问题),而这个类型声称为「不可变的直角三角形」。此外构造器里的/*D*/直接把参数写进表示而没有做防御性拷贝,/*B*/那个公开字段还让客户端依赖上了表示,破坏表示独立性。(原文的regularize()里把sides[0]误写成side[0],是一个原样保留的笔误;本笔记的示例代码中已改正为sides[0]以便编译。)Identity[] getSigners()(安全相关的表示暴露):设想一个只允许「已被可信身份签名」的类,它用自己的private Identity[]字段保存校验过的签名,而getSigners()直接把这个数组返回。客户端于是可以往数组里追加身份、把已有身份替换成别的身份,从而破坏一个安全相关的不变量——数组是可变类型,把它交出去等于把安全边界交出去。Date(可变类型的对照):java.util.Date是可变的,所以只要它出现在表示里,就必须在参数与返回值两处分别防御性拷贝;课程同时提醒,Java API 文档已把Date的大部分方法标为 deprecated,新代码不应使用它——换用不可变的java.time.ZonedDateTime才是根治。
- 关键规则与最佳实践:
- 构造器做入参防御性拷贝:
this.timestamp = new Date(timestamp.getTime());——在输入关口复制,把外部世界与你隔离。 - 观察者做出参防御性拷贝:
return new Date(timestamp.getTime());——在输出关口复制,防止客户端反手改你的内部状态。 - 返回新对象或不可变视图:能返回
String、Integer或不可变类型就不要返回可变容器;必须返回集合时考虑拷贝或不可变包装。 - 优先选择不可变类型:如果日期用的是不可变的
java.time.ZonedDateTime而不是可变的java.util.Date,那么讲完private/public这一节就可以结束了——不可能再有表示暴露。这是最省心的方案。 - 防止可变对象被多个对象共享:把客户端传入的数组、
Map、List直接存进表示,等于把表示的一部分放在客户端手里。一个同类型的例子见下面的Matrix(该示例出自 Reading 19(Programming with ADTs),不属于 Reading 11 原文,此处借用来说明表示暴露与深拷贝)。
- 构造器做入参防御性拷贝:
补充说明:另一个可变数组表示的示例 —— Matrix(出自 Reading 19,非本讲原文)。课程在后续的 Reading 19(Programming with ADTs)中给出了一个用二维数组当表示的矩阵类型,它的 AF/RI 与「构造器必须做防御性拷贝」这条纪律合在一起看,正好补全本讲的图景(原文在该构造器处留下了注释 // note: danger!):
class Matrix implements MatrixExpression {
private final double[][] array;
// Rep invariant:
// array.length > 0,且所有 array[i] 的长度相同且非零
// Abstraction function:
// AF(array) = 具有 array.length 行、array[0].length 列的矩阵,
// 其 (row, column) 元素为 array[row][column]
// Safety from rep exposure:
// 字段 private final;但 double[][] 是可变类型,
// 因此构造器必须做深拷贝(逐行复制),且不向任何操作返回内部数组。
public Matrix(double[][] array) {
// 课程原文此处只有 this.array = array; // note: danger!
// 正确做法:深拷贝,切断与调用者之间的别名
this.array = new double[array.length][];
for (int row = 0; row < array.length; row++) {
this.array[row] = array[row].clone();
}
checkRep();
}
/** @return 行数 */
public int rows() {
checkRep();
return array.length;
}
/** @return 第 row 行第 col 列的元素 */
public double get(int row, int col) {
checkRep();
return array[row][col]; // double 是原始类型,返回副本,不存在别名
}
private void checkRep() {
assert array != null;
assert array.length > 0;
assert array[0] != null && array[0].length > 0;
for (double[] row : array) {
assert row != null && row.length == array[0].length;
}
}
}
注意三个要点:第一,数组是可变类型,所以「字段私有 + final」完全不够——final 只锁住 array 这个引用,锁不住数组里的元素,也锁不住客户端手里那份原始二维数组的引用,因此构造器必须做深拷贝(只复制外层数组是不够的,内层数组仍被共享)。第二,checkRep() 直接对应 RI 的每一条,包括「所有行长相同且非零」这条跨行关系——RI 常常是字段之间的关系,而不只是单个字段的性质。第三,返回 double 这类原始类型不会产生别名,是整个类里唯一不需要防御性拷贝的访问路径。另外,若某个字段本身不该被客户端看到(例如缓存、索引),把它设为 private 并在 Safety 论证中单独说明其不可见性即可。
不可变包装器(Immutable Wrappers)
- 定义与目的:Java 集合库提供了一个折中方案:
Collections.unmodifiableList()、unmodifiableMap()、unmodifiableSet()等把可变的集合包装成一个「看起来一样、但所有修改者都抛异常」的对象。你可以用修改者把集合建好,然后用不可变包装把它封起来(并按 Reading 08 的建议丢掉对原始可变集合的引用),从而得到一个不可变的集合视图。 - 直观解释(”它是什么?”):像是给家里的电闸装了个只能看的玻璃罩:你还能读表,但伸不进去动手。它的代价是——只有运行时不可变,没有编译期不可变:编译期不会警告你调用
sort(),你只会在运行时拿到一个异常;而且如果谁还留着原始可变集合的引用并改了它,「不可变」视图的内容会随之改变,而且不会有任何报错。 - 关键规则与最佳实践:
- 用不可变包装降低 bug 风险,但别把它当成类型系统级别的保证。
- 包装之后立刻丢弃原始可变引用,否则「不可变」只是假象。
- 返回集合的观察者中,
List.of(...)、Collections.emptyList()这类工厂也有同样的「仅运行时不可变」局限。 - 若需要编译期保证,请自定义不可变类型(如
SortedCharSet),或者使用库提供的真正不可变类型。 - 在 Safety from rep exposure 注释里如实写明用的是哪种机制(拷贝 / 不可变包装 / 本身不可变),因为论证的说服力取决于机制的强度。
AF / RI / Safety 三件套注释(Documenting AF, RI, and Safety from Rep Exposure)
- 定义与目的:6.031 要求每个有实质表示的 ADT 在表示(私有字段)声明旁边写下三段注释:抽象函数、表示不变量、以及免于表示暴露的安全性论证(safety from rep exposure)。它们分别回答三个问题:这个表示被如何解释?什么样的表示是合法的?为什么客户端拿不到可变的内部表示?三者缺一不可——只写 RI 不写 Safety,你可能在「表示合法」的同时把它泄漏出去;只写 AF 不写 RI,
checkRep()无从下手;只写 Safety 不写 AF/RI,读者根本不知道你在保护什么。 - 直观解释(”它是什么?”):这是一份写给「未来的维护者」(包括三个月后的你自己)的表示使用说明书。它不是在描述类型做什么(那是公开规格的事),而是在描述实现内部约定,因此必须写在类体内部的普通注释里,而不是类上面的 Javadoc——写成 Javadoc 等于把它公开承诺为规格的一部分,会破坏表示独立性与信息隐藏。
- 关键规则与最佳实践:
- AF 行写成
AF(字段...) = 抽象值表达式,等号右边必须出现字段名,可代入求值。 - RI 行写成对字段的断言式条件,每条都应当能直接翻译成
assert。 - Safety 行逐个字段交代:字段是否
private;类型本身是否不可变;若可变,在哪里做了防御性拷贝或不可变包装;参数与返回值是否可能泄漏。注意Tweet的timestamp没有额外的 RI 条件,但它仍然必须出现在 Safety 论证里,因为整个类型的不可变性依赖于所有字段都不被改动。 - 写不清楚意味着什么:如果你写不出精确的 AF/RI,通常说明你对这个表示的语义自己也还没想清楚,或者表示设计本身就有问题;含糊的注释会让不同实现者产生分歧(本讲的
CharSet练习「Trying to implement without an AF/RI」正是展示这种灾难:Louis Reasoner 没写下 AF/RI,于是三位队友各自揣着SortedRep、SortedRangeRep、NoRepeatsRep、AnyRep四种不同理解去实现add()/remove()/contains(),结果每种实现只对其中一部分 AF/RI 成立)。 - 注释与代码要同步:修改表示时,AF/RI/Safety 三段注释是改动清单的第一项。
- AF 行写成
标准模板(必须完整书写,三行式注释缺一不可):
public class SomeType {
private final FieldType field1; // 表示(rep):私有字段
private final OtherType field2;
/**
* ...
* Abstraction function:
* AF(r) = ...
* Representation invariant:
* ...
* Safety from rep exposure:
* ...
*/
}
逐行解释:
Abstraction function:之后的AF(r) = ...描述表示值 → 抽象值的映射,r应当被写成本类的字段(或一个元组),等号右边必须真的用到这些字段,且能对具体表示值代入求值,最终得到唯一的抽象值。Representation invariant:之后描述合法表示值的集合:把任意字段取值代入,应当得到一个明确的真/假判断;只有为真的表示值才对应抽象值。它也是checkRep()里assert的逐条来源。Safety from rep exposure:之后描述为什么客户端拿不到可变内部表示的别名:逐个字段说明可见性与可变性,并指出在哪些环节(构造器入参、观察者返回值)做了防御性拷贝或不可变包装。- 上面三点合起来是「表示的完整语义」:解释(AF)+ 合法性(RI)+ 隔离(Safety)。少了任何一条,ADT 都不能算「守护住了自己的不变量」。
- 把注释放在字段声明旁边、用普通注释(
//)而非 Javadoc(/** */)放在类上方,因为它们属于实现,不属于规格。
下面的 FollowGraph 是本讲原文用来检验 Safety 论证是否合格的可变类型:原文把 Safety 一栏留成 ..???..,让读者自己补全——这正好说明一段合格的 Safety 论证必须逐个字段交代,而不是一句「所有字段都私有」了事:
// 可变类型:表示 Twitter 用户的关注关系
public class FollowGraph {
private final Map<String, Set<String>> followersOf;
// Rep invariant:
// followersOf 中的所有字符串都是 Twitter 用户名
// (即非空且仅含字母、数字、下划线的字符串)
// 没有用户关注自己,即 x 不在 followersOf.get(x) 中
// Abstraction function:
// AF(followersOf) = 这样的关注关系图:Twitter 用户 x 被用户 y 关注,
// 当且仅当 followersOf.get(x).contains(y)
// Safety from rep exposure:
// ..???..(原文留白,交由读者补全;合格答案必须逐个字段说明,
// 并明确指出 getFollowers() 返回的是防御性拷贝还是不可变包装,
// 以及 Map 是否可能作为参数或返回值出现)
// 操作(规格与方法体从略)
public FollowGraph() { ... }
public void addFollower(String user, String follower) { ... }
public void removeFollower(String user, String follower) { ... }
public Set<String> getFollowers(String user) { ... }
}
原文给出了六种候选说法让读者判断能否用来补全 ..???..:其中「Strings are immutable」不充分(漏掉了可变的 Set 与 Map);「本类是可变类型,所以不存在表示暴露问题」是错误的(可变类型的表示同样需要保护,否则客户端可以绕过 addFollower() 的契约、破坏「没有用户关注自己」这条 RI);「followersOf 从不出现在参数或返回值中」也要配合「getFollowers() 返回的 Set 做了什么处理」才成立。只有像「String 不可变;表示中的 Set 是可变类型,但 getFollowers() 返回的是全新的防御性拷贝而不是表示中任何集合的引用;表示中的 Map 是可变类型,但它从不出现在任何操作的参数或返回值中」这样逐字段、逐边界的表述,才构成完整而有力的论证。原文还提醒:Tweet 的 timestamp 没有任何额外的 RI 条件,却仍然必须写进 Safety 论证——因为整个类型的不可变性依赖于所有字段都不被改动(对照「一个关于可变 Date 的不合格论证」,本笔记在场景 2 中展开)。
checkRep():在运行时断言表示不变量(Checking the Rep Invariant at Runtime)
- 定义与目的:
checkRep()是一个私有方法,把 RI 逐条翻译成assert语句。RI 不只是漂亮的数学概念:只要在运行时断言它,你就能在 bug 刚产生的第一时间抓住它,而不是等损坏的数据结构继续传播、最后在完全无关的地方以莫名其妙的方式炸掉。 - 直观解释(”它是什么?”):它是你留在类内部的安检门。每次内部状态被重新构造或改动,都过一次安检;一旦某次改动让表示落到红灯区,程序立刻停在那里,而责任范围被限制在这个类内部。这也是为什么
checkRep()必须是private:不变量由实现自己负责检查和强制,而不是委托给客户端——客户端不该知道表示的存在,更不该被要求去验证它。 - 关键规则与最佳实践:
- 在每一个创建或修改表示的操作末尾调用:构造者(creator)、生产者(producer)、修改者(mutator)。例如
RatNum的两个构造器都在末尾调用checkRep()。 - 观察者(observer)也应调用:观察者本不需要,但这是良好的防御性实践——它让「由表示暴露引起的 RI 破坏」更早被发现(暴露出去的状态被外部改坏后,下一次观察时就会当场报错)。
- 用
assert而不是抛异常:assert表达的正是「这里应当永远为真,若为假说明实现有 bug」,语义精确,且可以被整体关闭。 - 注意断言默认是关闭的:
assert只在以-enableassertions(简写-ea)启动 JVM 时才生效;原课代码特意注释道:// *** Warning: this does nothing unless you turn on assertion checking by running Java with -enableassertions (or -ea)。测试时务必打开,否则checkRep()形同虚设。 checkRep()与 RI 严格对应:RI 里写了什么就断言什么;RI: true时checkRep()实际上没有业务断言(但隐含的!= null仍值得断言);注意int这类原始类型字段不可能为null,对它们写assert i != null是编译错误。- null 检查不该省:Java 中 RI 隐含要求每个对象引用非 null,因此
checkRep()应包含这些 null 检查——「尽早抓住 null bug」正是它的价值(sp22 用 TypeScript 的严格 null 检查在静态层面承担了这一职责)。
- 在每一个创建或修改表示的操作末尾调用:构造者(creator)、生产者(producer)、修改者(mutator)。例如
有益的可变性(Beneficent Mutation)
- 定义与目的:不可变的精确定义是「抽象值永不改变」,而不是「表示值永不改变」。既然 AF 是「多对一」的,实现完全可以在保持抽象值不变的前提下修改表示值——客户端观察不到任何差别。这种改动叫有益的可变性。它换来的往往是性能:缓存、数据结构再平衡、惰性清理。
- 直观解释(”它是什么?”):
RatNum的弱 RI 版本是最经典的例子:RI 只要求denominator != 0,于是连续的算术运算可以不约分;等到要给人类看结果时,toString()才把numerator、denominator同时除以gcd并调整符号,顺手把表示改成了最简形式。注意toString()在不可变类型上是个观察者方法,居然改写了两个private字段——但因为(1,2)和(2,4)通过AF(numerator, denominator) = numerator/denominator映射到同一个抽象值,这次改动对客户端完全不可见,因而是无害的、有益的。 - 直观解释续(迭代器为什么必须可变):
MyIterator是 Reading 08(可变性与不可变性)中给出的迭代器实现(不属于 Reading 11 原文,此处借用),它的next()规格明确写着Modifies: this iterator to advance it to the element following the returned element。从「集合的元素序列」这个抽象视角看,next()是在观察序列中的下一个元素;但从迭代器自身的抽象视角看,它同时又修改了迭代器的位置。于是它既是观察者又是修改者。hasNext()与next()的关系是一份契约:hasNext()为真 ⟺ 再调用一次next()会返回一个元素而不越界;next()的前置条件正是「hasNext()返回 true」。语义上,hasNext()观察「是否还有元素」,next()观察「下一个元素」并推进游标——正因为它推进了游标,同一个迭代器连续两次next()会返回不同元素(不像不可变对象那样可以重复观察同一结果),所以迭代器本身是可变的。 - 关键规则与最佳实践:
- 判据是抽象值,不是表示值:只要 AF 结果不变,改动就是合法的有益可变性;反之,即使只改了表示的一部分,只要抽象值变了,就不是有益可变性。
- 有益可变性不改契约:它不需要在公开规格里说明,客户端无从(也不应)观察它。
- 迭代器/游标类类型必须可变:把「位置」放进表示,就注定
next()是修改者;要重复遍历就新建一个迭代器。 - 修改者也要维护 RI:
next()推进index时必须保证0 <= index <= list.size(),并在返回前调用checkRep()。 - 缓存是典型场景:把已算出的结果记在表示里,重复查询直接命中(Reading 21 的
isPrime记忆化缓存就是这种「用空间换时间的表示内变更」),但要记得它在并发环境下会引入新的线程安全问题。
用 AF 定义 equals() 与 toString()(Equality and String Representation via the AF)
- 定义与目的:既然 AF 把表示值映射到抽象值,「两个对象相等」的正确判据就比较抽象值:当且仅当
AF(this.r) = AF(that.r)。对不可变类型而言,这与「观察相等性」一致——两个对象若无法通过该 ADT 规格中的任何操作区分开,就应当相等。toString()同理,应当输出抽象值(人类可读的形式),而不是把字段直接拼出来。 - 直观解释(”它是什么?”):以本讲原文的
RatNum为例,AF(numerator, denominator) = numerator/denominator。在「分母为正且已约分」的 RI 下,表示是唯一的,于是逐字段比较恰好等价于比较抽象值;但只要把 RI 放宽成「只要求denominator != 0」(这是原文明确认可的另一种设计),new RatNum(1, 2)与new RatNum(2, 4)就会表示同一个抽象值 1/2,而逐字段比较会把它们判为不等——正确的做法是this.numerator * that.denominator == that.numerator * this.denominator,或先化为最简再比较,即真正比较抽象值。这正是 AF 非单射带来的必然后果:表示不同不代表抽象值不同。toString()同理应当输出抽象值:原文中宽松 RI 版本的toString()先把结果化简(顺便改写字段,即有益的可变性),再输出numerator/denominator这样的人类可读形式,而不是把内部字段裸拼出来。(Duration(1,2)与Duration(0,62)、LetterSet("abc")与LetterSet("aBc")是同一现象的另外两个例子;这两个示例出自 Reading 15(相等性),不属于 Reading 11 原文,此处借用来说明同一概念。) - 关键规则与最佳实践:
equals()比较抽象值,实现方式是「类型检查 + 私有sameValue()助手」:return that instanceof RatNum && this.sameValue((RatNum) that);。instanceof在面向对象中通常是坏味道,唯一被允许的例外就是实现equals()(getClass()之类同理禁止)。- 必须用
@Override:签名写错会变成重载(overload)而不是覆盖(override),于是r1.equals(r2)与r1.equals(o2)(o2静态类型为Object)会给出不同答案,相等性变得不一致。@Override让编译器替你检查签名。 hashCode()必须与equals()一致:相等对象必须有相同的哈希值。规则是「覆盖equals()时就一定要覆盖hashCode()」;RatNum在规范表示下可以用31 * numerator + denominator(等价于对抽象值求哈希)。注意别把hashCode拼成hashcode——那只是新加了个方法,根本没有覆盖Object.hashCode()。equals()必须满足等价关系:自反、对称、传递,且x.equals(null)对非 null 的x返回false,并且结果稳定(只要对象没被改动)。原课 Reading 15 用给Duration加「时钟偏差容忍」的相等性来演示传递性如何被破坏,是必须避免的设计。toString()输出抽象值:例如return (denominator > 1) ? (numerator + "/" + denominator) : (numerator + "");,它显示的是有理数的值,而不是内部字段的裸拼装;也可以用它来检查「同一抽象值的两种表示是否打印一致」。- 可变类型的处理不同(详见 Reading 15):可变类型一般不应覆盖
equals()/hashCode(),而应继承Object的引用相等;若确实需要「看起来一样」的概念,另起名如similar()/sameValue()作为公开操作。本讲的FollowGraph就是这种可变类型——它不应按抽象值定义equals()。
不变量保持的证明规则(Establishing Invariants: Structural Induction)
- 定义与目的:这是本讲的理论结晶,教我们如何证明一个 ADT 的不变量在所有实例上都成立。不变量是「对整个程序为真」的性质,对对象而言就是「对该对象的整个生命周期为真」。要让它成立,需要做两件事:让它在对象的初始状态为真,并保证对该对象的所有改动都不破坏它。翻译成 ADT 操作的类型语言就是:
- 构造者(creators)与生产者(producers)必须为新实例确立不变量;
- 修改者(mutators)、观察者(observers)、生产者(producers)必须为已有实例保持不变量。
- 直观解释(”它是什么?”):这是一次结构归纳(structural induction):把所有实例按「被哪个操作造出来」分层。基例是新对象——由构造者与生产者负责建立;归纳步是旧对象被操作——由修改者、观察者、生产者负责保持。证明某个方法保持 RI 的范式完全固定:假设进入方法时 RI 成立(这就是前置条件),执行方法体,在每一个可被外部观察到(observable)的位置断言 RI 仍然成立。对不同类型的操作用不同的具体目标:
- 构造者:没有输入对象,必须建立 RI(还要让 AF 有定义,即产生合法表示)。
- 生产者:输入旧对象、输出新对象。要做两件事——保持输入对象的 RI(不要改坏它),并建立新对象的 RI(若新对象的构造通过构造者完成,那么这一步通常由构造者承担,但要确认生产者的参数传递没有引入非法值)。
- 观察者:必须保持 RI。观察者若能破坏 RI,唯一的现实原因就是它同时是修改者(例如签名里写着「读取并推进状态」的
next()),或者它把内部表示泄漏了出去,让别处的代码改坏了状态。 - 修改者:必须保持 RI,且这是最容易出错的一类——必须检查它在所有分支(包括提前
return、抛异常前)都把表示留在了绿灯区。
- 关键规则与最佳实践:
- 完整规则(课程原文的判据):如果一个 ADT 的不变量满足:由构造者与生产者确立;由修改者、观察者与生产者保持;并且不发生任何表示暴露——那么该不变量对该 ADT 的所有实例都成立。换句更简洁的话:前置条件假设 RI;方法体执行;在每个可观察处断言 RI 仍成立。
- 表示暴露让证明失效:这是第三条为什么必须写进来的原因——如果表示被暴露,对象可能在程序的任意位置被改动,而这些改动不在任何一个操作的「方法体」之内,你根本没有地方去断言 RI,归纳步的每个证明义务都随之作废。
- 观察者不能破坏 RI,除非它本身是修改者或发生了暴露:给一个纯观察者写「它保持 RI」的证明通常是一行话(它不写任何字段);真正需要警觉的是它是否泄漏了可变表示的别名。
- 注意参数别名:即便是观察者,如果它返回内部可变对象,客户端随后修改该对象就等于在类外修改表示——RI 的证明从此不成立。
- 把证明义务落实为代码:RI 的建立与保持最终体现为各方法末尾的
checkRep();这不是形式化证明,但能在运行时以极低成本覆盖绝大多数数学证明的错误。
规格说明可以谈论什么 / 用 ADT 不变量替代前置条件(What a Spec May Talk About / ADT Invariants Replace Preconditions)
- 定义与目的:既然 AF/RI 属于实现内部,那么类型
T的规格(即其各操作的规格)就只能谈论客户端可见的东西:参数、返回值、抛出的异常。凡规格中需要提到类型T的值时,都应当把它描述为抽象值(抽象值空间A中的数学值),而不提表示空间R的任何细节。把表示当作对客户端不可见,正如方法体与局部变量对客户端不可见一样——这也解释了为何 AF/RI 写作类体内部的普通注释,而不是类上方的 Javadoc。 - 直观解释(”它是什么?”):更好的设计是把前置条件变成 ADT。与其写一个前置条件冗长的方法
static String exclusiveOr(String set1, String set2),并在文档里要求「set1是排序且无重复的字符集」,不如让类型本身承担这个性质:static SortedSet<Character> exclusiveOr(SortedSet<Character> s1, SortedSet<Character> s2)。这样三个目标同时达成:更安全(「有序且无重复」这个条件只需在一个地方强制——SortedSet类型本身,而且静态检查会在编译期拒绝不满足条件的值)、更易理解(签名更简单,类型名SortedCharSet已经传达了必要信息)、更易修改(表示可以随意更换,exclusiveOr及其所有客户端都不必改动)。 - 关键规则与最佳实践:
- 规格里只出现抽象值:不要在公开文档里写下私有字段名、表示细节或
checkRep()的存在。 - AF/RI/Safety 用普通注释写在字段旁,不要写成 Javadoc。
- 能封装成类型的约束,就不要写成前置条件:课程早期习题里大量用前置条件表达的约束,其实都更适合定义一个自定义 ADT。
- 类型名应当传达不变量(如
SortedCharSet、Username),让编译器替你守住一部分契约。 - 为消除前置条件而抽取的 ADT 要小而专一:例如把「不重名且非空的用户名」抽成
Username,把「时间戳互不相同的推文集合」抽成TweetList。
- 规格里只出现抽象值:不要在公开文档里写下私有字段名、表示细节或
代码示例与对比分析
场景 1:不可变直角三角形把内部 double[] 数组直接交给客户端(表示暴露的经典反例)
❌ 错误代码
/** Represents an immutable right triangle. */
public class RightTriangle {
private double[] sides; // /*A*/
public final double hypotenuse; // /*B*/
/**
* Make a right triangle.
* @param legA, legB the two legs of the triangle
* @param hypotenuse the hypotenuse of the triangle,
* requires hypotenuse^2 = legA^2 + legB^2
* (within the error tolerance of double arithmetic)
*/
public RightTriangle(double legA, double legB, double hypotenuse) {
this.sides = new double[] { legA, legB }; // /*D*/
this.hypotenuse = hypotenuse; // /*D*/
}
/**
* Get the two sides of the right triangle.
* @return two-element array with the triangle's side lengths
*/
public double[] getAllSides() {
return sides; // /*E*/ 泄漏内部数组
}
/**
* @param factor to multiply the sides by
* @return a triangle made from this triangle by
* multiplying all side lengths by factor.
*/
public RightTriangle scale(double factor) {
return new RightTriangle(sides[0]*factor, sides[1]*factor, hypotenuse*factor);
}
/**
* @return a regular triangle made from this triangle.
* A regular right triangle is one in which
* both legs have the same length.
*/
public RightTriangle regularize() {
// 原文此处写成了 double bigLeg = Math.max(side[0], side[1]);
// (sp21/sp22 原文的笔误,会编译失败;此处按本意更正为 sides[0]/sides[1])
double bigLeg = Math.max(sides[0], sides[1]);
return new RightTriangle(bigLeg, bigLeg, hypotenuse);
}
// 没有 AF / RI / Safety 注释,也没有 checkRep()
}
【错误代码的问题】
/*E*/处的getAllSides()把内部数组本身返回出去,于是客户端写double[] s = t.getAllSides(); s[0] = 100;就改掉了这个「不可变」三角形的边长——而hypotenuse已经固定,勾股关系这条不变量当场失效,且没有任何地方会报警。/*B*/处把hypotenuse声明为public final字段:客户端从此依赖具体表示(表示独立性被破坏),而final只保证这个字段不能被重新赋值,完全不保证表示可以被替换。- 构造器只把两个
double参数(原始类型,不存在别名问题)装进新数组,看起来「已经拷贝过了」,但一旦参数类型换成可变对象(数组、Date、集合),同样的写法就会把调用者的对象直接存进表示——这个陷阱被/*D*/的写法掩盖了。 - 完全没有 AF / RI / Safety 注释,也没有
checkRep():类头声称自己是「immutable」的,而这条最重要的性质没有任何书面论证,也没有任何运行时的检查。
✅ 正确代码
/**
* Represents an immutable right triangle.
*/
public class RightTriangle {
private final double[] sides; // sides[0], sides[1] 是两条直角边的长度
// Rep invariant:
// sides != null, sides.length == 2
// sides[0] > 0 and sides[1] > 0
// Abstraction function:
// AF(sides) = the right triangle whose two legs have lengths
// sides[0] and sides[1]
// Safety from rep exposure:
// 字段 sides 是 private final;
// double[] 是可变类型,因此接受数组的构造器对它做防御性拷贝,
// 并且没有任何操作把内部数组本身交出去:
// getAllSides() 返回一份新数组,getSide(int) 返回原始类型 double(值拷贝)。
/**
* Make a right triangle.
* @param legA, legB the two legs of the triangle; both must be > 0
*/
public RightTriangle(double legA, double legB) {
this.sides = new double[] { legA, legB };
checkRep();
}
/**
* Make a right triangle from an array of leg lengths.
* @param legs two-element array of positive leg lengths;
* this object does not alias the caller's array
*/
public RightTriangle(double[] legs) {
this.sides = legs.clone(); // 入参防御性拷贝:切断与调用者的别名
checkRep();
}
/**
* Get the two sides of the right triangle.
* @return a fresh two-element array with the triangle's side lengths
*/
public double[] getAllSides() {
checkRep();
return sides.clone(); // 出参防御性拷贝
}
/**
* @param i 0 or 1
* @return length of leg i
*/
public double getSide(int i) {
checkRep();
return sides[i]; // double 是原始类型,返回的是副本
}
/**
* @return length of the hypotenuse
*/
public double getHypotenuse() {
checkRep();
// 由两条直角边计算得出,而不是把斜边也存进表示
return Math.sqrt(sides[0]*sides[0] + sides[1]*sides[1]);
}
/**
* @param factor to multiply the sides by, must be > 0
* @return a triangle made from this triangle by
* multiplying all side lengths by factor.
*/
public RightTriangle scale(double factor) {
return new RightTriangle(sides[0]*factor, sides[1]*factor);
}
/**
* @return a regular triangle made from this triangle.
* A regular right triangle is one in which
* both legs have the same length.
*/
public RightTriangle regularize() {
double bigLeg = Math.max(sides[0], sides[1]);
return new RightTriangle(bigLeg, bigLeg);
}
private void checkRep() {
assert sides != null;
assert sides.length == 2;
assert sides[0] > 0 && sides[1] > 0;
}
}
【为什么这样更好】
- 表示中的数组不再有机会离开类:接受数组的构造器用
legs.clone()切断入参别名,getAllSides()返回sides.clone()切断出参别名,getSide(int)返回原始类型double,根本不产生别名。三条路径合起来才构成完整的 Safety 论证。 - 斜边不再作为字段存储,而是由两条直角边计算得出。这不只是省了一个字段:它把「勾股关系」这条原本需要被小心维护的不变量,变成了由构造方式自动成立的事实——破坏一条不可能被违反的规则,比事后检查它更可靠。
- AF / RI / Safety 三段注释与
checkRep()齐备,且checkRep()逐条对应 RI,在每个公开方法返回前调用。 scale()与regularize()这两个生产者都通过构造器产生新对象,因此新对象的 RI 由构造器负责建立——这正是「生产者必须确立新实例的不变量」。
【代码对比解说】 两个版本的字段声明都写得「很像不可变」:一个是 private double[],另一个是 public final double。问题恰恰在这里——final 锁住的是引用而不是数组内容,private 锁住的是字段名而不是你已经交出去的别名。错误版本在 /*E*/ 一处失守,整条不可变性就作废了;而且失守之后 hypotenuse 无法跟着变化,类的抽象值直接进入自相矛盾的状态。原文把这段代码标上 /*A*/ 到 /*E*/ 五个位置让读者判断,按原文原则推理:/*B*/(公开字段让客户端依赖表示)与 /*E*/(返回内部数组威胁不可变性)确实成立;/*A*/(私有数组字段本身)只是「风险」而非暴露,暴露发生在引用外泄之时;/*C*/ 不成立——构造者完全可以有前置条件(本讲 Tweet 的构造器就有);/*D*/ 也不成立——legA、legB、hypotenuse 都是原始类型 double,不存在别名,无需拷贝。最后一点尤其值得记住:判断是否需要防御性拷贝,看的是类型是否可变,而不是看这个字段是不是「重要」。
【设计原则透视】 本组是 Safety from rep exposure 与 RI 保持证明的交汇点。返回内部数组会让不变量在 ADT 的操作之外被破坏,于是「不变量由构造者与生产者确立、由修改者与观察者保持」这条证明规则的第三个条件(不发生表示暴露)失效,整个归纳证明随之崩溃——这就是原文为什么把「no representation exposure」单列为规则的一条。它也直接呼应 Reading 08(不可变性)中「共享可变对象会破坏不可变性」的结论,以及 Reading 10(抽象数据类型)中表示独立性的要求:只要 getAllSides() 返回内部数组,客户端就会开始依赖「内部就是一个 double[2]」,你再也换不动这个表示。
场景 2:Tweet 的可变 Date 字段未做防御性拷贝,且缺少 AF/RI/Safety 与 checkRep()
❌ 错误代码
import java.util.Date;
/** Immutable type representing a tweet. */
public class Tweet {
private final String author;
private final String text;
private final Date timestamp;
public Tweet(String author, String text, Date timestamp) {
this.author = author;
this.text = text;
this.timestamp = timestamp; // 保存了调用者的 Date 引用
}
public String getAuthor() { return author; }
public String getText() { return text; }
public Date getTimestamp() { return timestamp; } // 把内部 Date 交出去
// 没有 AF / RI / Safety 注释,也没有 checkRep()
// 也没有对 author 格式、text 长度 280 的任何检查
}
【错误代码的问题】
getTimestamp()返回的Date与t.timestamp是同一个对象。原文的客户端代码Date d = t.getTimestamp(); d.setHours(d.getHours()+1);是完全合理的写法,却顺手改掉了t内部的时间——Tweet的不可变性不变量当场被破坏,而Tweet的代码一行都没被执行。- 构造器直接保存传入的
Date,原文的tweetEveryHourToday()例子因此出错:它想用一个Date对象依次走过一天 24 小时、每小时造一条推文,但由于 24 个Tweet共享同一个Date,最终所有推文的时间戳都相同。 - 没有 Safety 论证,维护者无从知道
timestamp需要拷贝。特别注意:timestamp并没有额外的 RI 条件,但它仍然必须出现在 Safety 论证中,因为整个类型的不可变性依赖于所有字段都不被改动——省略它是最常见的错误。 - 没有
checkRep(),RI 里「author是 Twitter 用户名」「text.length <= 280」这两条完全靠调用者自觉;违反后对象会带着非法表示继续存在。
✅ 正确代码
import java.util.Date;
// Immutable type representing a tweet.
public class Tweet {
private final String author;
private final String text;
private final Date timestamp;
// Rep invariant:
// author is a Twitter username (a nonempty string of letters, digits, underscores)
// text.length <= 280
// Abstraction function:
// AF(author, text, timestamp) = a tweet posted by author, with content text,
// at time timestamp
// Safety from rep exposure:
// All fields are private;
// author and text are Strings, so are guaranteed immutable;
// timestamp is a mutable Date, so Tweet() constructor and getTimestamp()
// make defensive copies to avoid sharing the rep's Date object with clients.
/**
* Make a Tweet.
* @param author Twitter user who wrote the tweet
* @param text text of the tweet
* @param timestamp date/time when the tweet was sent
*/
public Tweet(String author, String text, Date timestamp) {
this.author = author;
this.text = text;
this.timestamp = new Date(timestamp.getTime()); // 入参防御性拷贝
checkRep();
}
/** @return Twitter user who wrote the tweet */
public String getAuthor() {
checkRep();
return author; // String 不可变,无需拷贝
}
/** @return text of the tweet */
public String getText() {
checkRep();
return text;
}
/** @return date/time when the tweet was sent */
public Date getTimestamp() {
checkRep();
return new Date(timestamp.getTime()); // 出参防御性拷贝
}
// Check that the rep invariant is true
// *** Warning: this does nothing unless you turn on assertion checking
// by running Java with -enableassertions (or -ea)
private void checkRep() {
assert author != null && text != null && timestamp != null;
assert author.matches("[A-Za-z0-9_]+") : "not a Twitter username: " + author;
assert text.length() <= 280 : "tweet too long: " + text.length();
}
}
【为什么这样更好】
- 上面的三段注释就是课程原文给出的完整写法(RI 用
text.length <= 280表述,在 Java 中即text.length() <= 280):AF 说明「字段被解释成什么」,RI 说明「什么样的字段值合法」,Safety 逐字段交代隔离方式。 - 构造器与观察者各做一次防御性拷贝,于是
retweetLater、tweetEveryHourToday这两段「完全合理」的客户端代码再也不可能改坏内部表示。 checkRep()把 RI 的两条要求变成运行时可执行的断言,并在每个公开方法返回前调用;调用观察者也要检查,这样「由表示暴露引起的不变量破坏」会更早暴露。- 更好的做法是把
Date换成不可变的java.time.ZonedDateTime:那样连两次拷贝都可以省去,Safety 论证退化成「所有字段私有且表示中所有类型不可变」这一句话。原文还提醒,Java API 文档已把Date的大部分方法标为 deprecated,新代码不应使用它。
【代码对比解说】 两版代码的字段声明一模一样(private final),唯一的差别是在边界处是否拷贝,以及是否把约定写下来。这说明表示暴露的本质不是字段可见性,而是引用的流向:任何可变对象只要跨过类边界(作为参数进来、或作为返回值出去),就必须拷一份。原文给这段代码留了一个更早的版本:字段直接写成 public String author; public String text; public Date timestamp;,于是客户端一句 t.author = "rbmllr"; 就能改掉推文作者——那是最直白的表示暴露,它同时破坏不变量与表示独立性(客户端从此依赖「作者存在 author 字段里」)。此外原文还讨论了一种「用规格免责」的诱惑:在文档里写「调用者此后绝不能再修改这个 Date 对象!」。这种做法只在别无选择时(例如可变对象太大、拷贝代价过高)才可接受,因为它把「保证不变量」的责任推给了每一个调用者,代价是极大的推理成本。
【设计原则透视】 本组是 Safety from rep exposure 论证的教科书范例,也是 Reading 08(不可变性)中「拒绝表示暴露」的具体落实:不可变性不是靠 final 拿到的,而是靠「不共享可变对象」拿到的。它同时把 Reading 10(抽象数据类型)的「ADT 守护自己的不变量」落到了参数与返回值这两个最容易被忽略的边界上;而 checkRep() 则把 Reading 09(避免调试)的策略——让 bug 在离根因最近的地方暴露——落实为一行断言。
场景 3:用表示值而非抽象值实现 equals()(同一有理数被判成两个值)
❌ 错误代码
/**
* Immutable type representing a rational number.
*
* 这里刻意采用原文认可的「更宽松的 RI」:只要求分母非零,不要求已约分。
*/
public class RatNum {
private final int numerator;
private final int denominator;
// Rep invariant:
// denominator != 0
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
// Safety from rep exposure:
// All fields are private, and all types in the rep are immutable.
public RatNum(int n, int d) {
if (d == 0) throw new ArithmeticException("denominator is zero");
this.numerator = n;
this.denominator = d;
checkRep();
}
public int getNumerator() { return numerator; }
public int getDenominator() { return denominator; }
// 错误:逐个字段比较「表示值」
@Override
public boolean equals(Object that) {
if (!(that instanceof RatNum)) return false;
RatNum r = (RatNum) that;
return this.numerator == r.numerator
&& this.denominator == r.denominator;
}
// 错误:哈希也只依赖表示值
@Override
public int hashCode() {
return 31 * numerator + denominator;
}
private static int gcd(int a, int b) {
a = Math.abs(a); b = Math.abs(b);
while (b != 0) { int t = a % b; a = b; b = t; }
return a;
}
private void checkRep() {
assert denominator != 0;
}
}
【错误代码的问题】
new RatNum(1, 2)与new RatNum(2, 4)的抽象值完全相同——AF(numerator, denominator) = numerator/denominator把两者都映射到 1/2——但逐字段比较把它们判为不等。这与 AF 定义的相等性直接矛盾,也违反观察相等性:除了getNumerator()/getDenominator()这两个把表示细节暴露给客户端的观察者,没有任何规格内的操作能区分它们。hashCode()同样基于表示值,于是这两个「本应相等」的对象哈希值不同,放进HashSet/HashMap后会分别落到不同的桶里——查找失败,而且不会有任何报错。- 相等性的正确性被 RI 的强弱绑架:本版本刻意使用宽松 RI(原文明确说这是合理的设计,某些操作更便宜、某些更贵),而宽松 RI 恰恰意味着「同一抽象值有多种表示」,此时逐字段比较必然是错的。
getNumerator()/getDenominator()让客户端可以自己写出「比较表示」的逻辑,等于把表示写进了客户端的语义里——表示独立性的损失会在下一次更换表示时集中爆发。
✅ 正确代码
/**
* Immutable type representing a rational number.
*/
public class RatNum {
private final int numerator;
private final int denominator;
// Rep invariant:
// denominator != 0
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
// Safety from rep exposure:
// All fields are private, and all types in the rep are immutable.
public RatNum(int n, int d) {
if (d == 0) throw new ArithmeticException("denominator is zero");
this.numerator = n;
this.denominator = d;
checkRep();
}
/** @return 该有理数的近似浮点值 */
public double value() {
checkRep();
return (double) numerator / denominator;
}
@Override
public boolean equals(Object that) {
return that instanceof RatNum && this.sameValue((RatNum) that);
}
// 返回 true 当且仅当 this 与 that 表示同一个抽象值
private boolean sameValue(RatNum that) {
// 比较抽象值:n1/d1 == n2/d2 等价于 n1*d2 == n2*d1
// (此处用 long 承接乘法;原文的 sp22 版本用 bigint 实现 RatNum,
// 正是为了彻底避免这类溢出问题)
return (long) this.numerator * that.denominator
== (long) that.numerator * this.denominator;
}
@Override
public int hashCode() {
// 先把表示化为最简形式再求哈希,这样「抽象值相等 ⇒ 哈希相等」必然成立
int g = gcd(numerator, denominator);
int n = numerator / g;
int d = denominator / g;
if (d < 0) { n = -n; d = -d; }
return 31 * n + d;
}
@Override
public String toString() {
// 输出抽象值:最简形式的人类可读写法,而不是内部字段的裸拼装
int g = gcd(numerator, denominator);
int n = numerator / g;
int d = denominator / g;
if (d < 0) { n = -n; d = -d; }
return (d > 1) ? (n + "/" + d) : (n + "");
}
private static int gcd(int a, int b) {
a = Math.abs(a); b = Math.abs(b);
while (b != 0) { int t = a % b; a = b; b = t; }
return a;
}
private void checkRep() {
assert denominator != 0;
}
}
【为什么这样更好】
sameValue()通过交叉相乘比较抽象值,因此new RatNum(1, 2).equals(new RatNum(2, 4))返回true——这正是AF定义的相等性;new RatNum(1, 2)与new RatNum(2, 3)仍然不等。hashCode()先把表示化成最简形式再计算,保证「相等 ⇒ 哈希相同」,因此这些对象可以安全地作为HashMap的键或放进HashSet。toString()输出抽象值(最简分式),于是同一个抽象值的不同表示打印结果一致——这也是检查equals/hashCode是否正确的一个实用手段。equals(Object)用@Override覆盖而不是重载,(RatNum) that是类型转换,向编译器声明「经instanceof检查后我确信它是RatNum」。
【代码对比解说】 两版代码的差别只在 sameValue 与 hashCode 的几行,但这几行决定了「AF 非单射」这一数学事实是否被正确对待。一个重要的细节:如果采用原文那种「分母为正且已约分」的严格 RI,表示是规范化的,逐字段比较恰好等价于比较抽象值——很多实现者因此「碰巧」写对了。但这种正确性是脆弱的:只要 RI 被放宽(原文明确指出这是完全合理的设计选择),或者某天有人加了一条不约分的快速路径,逐字段比较就立刻变成 bug。所以正确的纪律是:永远按抽象值思考相等性,需要时再借助「规范化表示」作为优化。另外要避免在 equals 里拿 toString() 做比较——它把相等性建立在另一个方法的具体实现上,还会带来「new RatNum(1, 2).equals("1/2") 返回 true」这类荒谬结果。修改变换时也别忘 @Override:Reading 15 中「equals(Duration that) 被误当作覆盖」的经典错误正是由它拦下的。
【设计原则透视】 本组是 AF 的直接应用:抽象函数是相等性的定义基础,因此不可变类型必须覆盖 equals(),从而也必须覆盖 hashCode()(Reading 15 的核心结论)。它也说明为什么表示独立性在相等性上尤其关键:只要 equals 比较抽象值,你就能把 RatNum 的表示从「分子/分母两个 int」换成「已化简易形式」或「分子分母两个 BigInteger」,而所有客户端与测试一行都不用改;反过来,一旦 equals 比较字段,客户端就把你的表示永久锁定了。它还回指本讲的 FollowGraph:可变类型一般不应按抽象值定义 equals(),而应继承 Object 的引用相等——原文与 Reading 15 都对这一点给出了明确结论。
场景 4:构造者没有建立 RI(未约分、分母可为 0 或负数),且没有 checkRep()
❌ 错误代码
/**
* Immutable type representing a rational number.
*/
public class RatNum {
private final int numerator;
private final int denominator;
// Rep invariant:
// denominator > 0
// numerator/denominator is in reduced form,
// i.e. gcd(|numerator|,denominator) = 1
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
// Safety from rep exposure:
// All fields are private, and all types in the rep are immutable.
/**
* Make a new RatNum == (n / d).
* @param n numerator
* @param d denominator
*/
public RatNum(int n, int d) {
this.numerator = n; // 未约分
this.denominator = d; // 分母可能为 0,也可能为负数
// 没有调用 checkRep()
}
/** @return 该有理数的近似浮点值 */
public double value() {
return (double) numerator / denominator;
}
@Override
public String toString() {
return numerator + "/" + denominator;
}
}
【错误代码的问题】
new RatNum(2, 4)违反 RI 的「已约分」条件——gcd(2, 4) = 2 ≠ 1。表示落进红灯区之后,AF 与 RI 给出的承诺(例如「表示唯一,可以逐字段比较」)全部失效,任何依赖规范形式的代码都会出错。new RatNum(1, 0)构造出一个分母为 0 的对象:它的抽象值根本不是有理数,value()返回Infinity,toString()打印"1/0"。错误被制造出来时没有任何提示,直到很久以后在别处炸开。new RatNum(1, -2)让分母为负,同样违反 RI;toString()打出"1/-2"这种与"-1/2"表示同一抽象值却有不同写法的结果。- 没有
checkRep():上面三种非法表示没有任何一处在构造完成时被发现,类头声称的 RI 只是注释而已——「不变量没有被写下来并被检查,就不是安全的不变量」。
✅ 正确代码
/**
* Immutable type representing a rational number.
*/
public class RatNum {
private final int numerator;
private final int denominator;
// Rep invariant:
// denominator > 0
// numerator/denominator is in reduced form,
// i.e. gcd(|numerator|,denominator) = 1
// Abstraction function:
// AF(numerator, denominator) = numerator/denominator
// Safety from rep exposure:
// All fields are private, and all types in the rep are immutable.
/**
* Make a new RatNum == n.
* @param n value
*/
public RatNum(int n) {
this.numerator = n;
this.denominator = 1;
checkRep();
}
/**
* Make a new RatNum == (n / d).
* @param n numerator
* @param d denominator
* @throws ArithmeticException if d == 0
*/
public RatNum(int n, int d) {
if (d == 0) throw new ArithmeticException("denominator is zero");
// reduce ratio to lowest terms
int g = gcd(n, d);
int reducedNumerator = n / g;
int reducedDenominator = d / g;
// make denominator positive
if (reducedDenominator < 0) {
this.numerator = -reducedNumerator;
this.denominator = -reducedDenominator;
} else {
this.numerator = reducedNumerator;
this.denominator = reducedDenominator;
}
checkRep(); // 构造者必须确立不变量
}
/** @return 该有理数的近似浮点值 */
public double value() {
checkRep();
return (double) numerator / denominator;
}
@Override
public String toString() {
return (denominator > 1) ? (numerator + "/" + denominator)
: (numerator + "");
}
private static int gcd(int a, int b) {
a = Math.abs(a); b = Math.abs(b);
while (b != 0) { int t = a % b; a = b; b = t; }
return a;
}
// Check that the rep invariant is true
// *** Warning: this does nothing unless you turn on assertion checking
// by running Java with -enableassertions (or -ea)
private void checkRep() {
assert denominator > 0;
assert gcd(Math.abs(numerator), denominator) == 1;
}
}
【为什么这样更好】
- 构造器在返回前把表示规约到唯一的合法形式:先
gcd约分,再统一符号使分母为正,d == 0则立刻抛出异常——这就是「构造者必须确立不变量」的具体动作。 checkRep()逐条对应 RI(分母为正、已约分),并在每个构造器与每个观察者返回前调用;构造时若哪一步写错,断言会立刻指出问题出在这里。- 表示被规范化之后,
toString()可以直接输出抽象值而不必再化简,equals/hashCode也可以直接依赖规范形式(详见场景 3 的讨论)。 - 两个构造器(
RatNum(int n)与RatNum(int n, int d))都调用checkRep(),因此「每个构造者都要建立 RI」这条要求没有漏网之鱼。
【代码对比解说】 这一组的问题不在观察者,而在构造者——也就是「不变量确立」这一环。原文的完整规则是:不变量必须由构造者与生产者确立、由修改者、观察者与生产者保持,并且不发生表示暴露。错误版本把 RI 写得很清楚却完全不去建立它,于是每个对象从出生起就可能是非法的。同样的错误也会发生在修改者身上:例如一个用 char[] 表示字符集合的 CharSet(本讲原文的 CharSet 用 String 表示,这里借用同一个抽象),其 add(char c) 在写入 chars[size] = c; ++size; 之后忘记调用 checkRep(),那么一旦边界判断写错,非法状态就会静默留存,直到某个完全无关的地方爆出莫名其妙的错误。这也解释了为什么 checkRep() 的调用点应当是「每个可能改动表示的路径的出口」,而不是「记得起来的时候」。另一个现实提醒:assert 默认关闭,只有在 -ea(-enableassertions)下运行 JVM 时这些检查才真正生效——课程原文特意把这条警告写在了 checkRep() 旁边。
【设计原则透视】 本组对应 RI + checkRep() + 不变量的建立/保持规则,是「证明一个 ADT 的不变量对所有实例成立」这条结构归纳规则的基例部分:构造者没有基例,就必须亲手建立 RI。它同时展示了 RI 的强弱如何成为设计权衡——原文指出,完全可以用更宽松的 RI(只要求分母非零)实现同一个 ADT,代价是别的操作要付出更多(例如相等性必须比较抽象值而不能比较表示值,如场景 3 所示)。这与 Reading 06(规格说明)和 Reading 07(设计规格)相连:规格只谈抽象值,而「什么表示合法、如何解释表示」属于实现文档,写在类体内部的普通注释里,不进入公开规格。
场景 5:迭代器的可变性、契约与 RI 维护(next() 同时是观察者与修改者) (该示例的迭代器 MyIterator 出自 Reading 08(可变性与不可变性),不属于 Reading 11 原文;本讲的可变性/不变量理论正好解释了它为什么必须可变、以及修改者如何保持 RI,故在此借用。)
❌ 错误代码
import java.util.List;
/**
* 可变类型:从前到后遍历 List<String> 中元素的迭代器。
*/
public class MyIterator {
private final List<String> list;
private int index; // list[index] 是下一次 next() 将返回的元素
public MyIterator(List<String> list) {
this.list = list;
this.index = 0;
}
/** @return true 表示还有元素可返回 */
public boolean hasNext() {
return index < list.size();
}
/** @return 下一个元素 */
public String next() {
return list.get(index++); // 没有前置条件检查,越界时抛 IndexOutOfBoundsException
}
/** @return 剩余元素个数 */
public int remaining() {
return list.size() - index; // 若 index 被推进过头,这里会返回负数
}
}
【错误代码的问题】
- 没有 RI 注释说明
index的合法范围(0 <= index <= list.size()),也没有checkRep(),于是index可以停留在非法状态(例如被推进到list.size()之外)而无人察觉。 next()没有声明前置条件「hasNext()返回 true」,越界行为的契约含糊;客户端可以在空迭代器上调用它,得到的是底层List的实现细节异常,而不是类型的契约。remaining()若不遵守 RI 就会返回负数,产生「逻辑上不可能」的观察结果——这是典型的 RI 破坏后静默传播错误的例子。- 规格里没有写明
next()会同时修改迭代器状态,接手的程序员可能误以为可以重复观察同一结果。
✅ 正确代码
import java.util.ArrayList;
import java.util.List;
/**
* 可变类型:从前到后遍历 List<String> 中元素的迭代器。
*/
public class MyIterator {
private final List<String> list;
private int index; // 表示:list[index] 是下一次 next() 将返回的元素;
// index == list.size() 表示已无元素可返回
// Rep invariant:
// list != null,且 list 中不含 null 元素
// 0 <= index <= list.size()
// Abstraction function:
// AF(list, index) = 序列 list[index..list.size()-1],
// 即本迭代器接下来将依次返回的元素序列
// Safety from rep exposure:
// list 与 index 都是 private;
// index 是原始类型 int,list 从不作为参数或返回值暴露;
// list 是构造时持有的引用,本类不修改它(它是共享的、由客户端持有的对象,
// 但本类只读,且它不属于本类的可变表示 —— 本类的可变表示是 index)。
// 注意:若客户端在迭代过程中修改该 list,迭代结果将不再有定义,
// 这与 java.util.Iterator 的 "fail-fast" 契约属同一类问题。
/**
* 构造一个迭代器。
* @param list 要遍历的列表,不允许为 null
*/
public MyIterator(List<String> list) {
this.list = new ArrayList<>(list); // 防御性拷贝,隔离客户端的后续修改
this.index = 0;
checkRep();
}
/**
* 测试迭代器是否还有元素可返回。
* @return 若 next() 仍能返回一个元素则为 true,否则为 false
*/
public boolean hasNext() {
checkRep();
return index < list.size();
}
/**
* 取得列表的下一个元素。
* 前置条件:hasNext() 返回 true。
* 修改:将本迭代器推进到被返回元素之后的位置。
* @return 列表的下一个元素
*/
public String next() {
checkRep();
final String element = list.get(index);
++index; // 有益的状态推进:迭代器的抽象值随之改变
checkRep(); // 修改后立即断言 RI 仍成立
return element;
}
/** @return 剩下还会返回的元素个数 */
public int remaining() {
checkRep();
return list.size() - index;
}
private void checkRep() {
assert list != null;
assert 0 <= index && index <= list.size() : "index 越界: " + index;
}
}
【为什么这样更好】
- RI 明确了
index的合法范围,checkRep()把它变成可执行断言;next()修改表示后立即断言 RI,正是「修改者必须保持不变量」的落地方式。 - 规格里显式写出
next()既是观察者(返回序列中的下一个元素)又是修改者(推进迭代器位置),并写出它的前置条件hasNext() == true——hasNext()与next()的关系因此清晰:前者判断「还能不能再调用一次next()」,后者要求这个判断为真。 remaining()在 RI 成立时不可能返回负数,因为index <= list.size()由不变量保证。- 构造器对
list做防御性拷贝,避免客户端在迭代过程中改动列表内容而使迭代器语义失去定义。
【代码对比解说】 迭代器是本讲中「可变性可以有益」的最好教材。若把 index 固定住,迭代器就只能反复观察同一个元素——因此迭代器必须是可变的:它的抽象值本身就随 next() 改变(这不是有益的可变性,而是正当的修改者行为)。与之相对的是 RatNum 弱 RI 版本中 toString() 改写字段却不改变抽象值,那才是「有益的可变性」(beneficent mutation)。两种情形都必须满足同一条纪律:改动之后 RI 仍然成立。checkRep() 放在 next() 内推进 index 之后,而不是只在方法开头,正是这个纪律的体现。最后要注意:对 list 做拷贝的取舍是设计选择——java.util.Iterator 选择不拷贝并采用 fail-fast 检测,两条路都要在 Safety 论证与规格中如实写明。
【设计原则透视】 本组把 RI 的保持证明落实到修改者身上,并展示了观察者与修改者边界的情形(next() 两者兼具)。它也串起 Reading 08(不可变性)中关于 final 与迭代器的讨论——final 锁住引用、锁不住被指向对象的可变内容;以及 Reading 21(并发)的伏笔:可变表示一旦被多个线程共享,checkRep() 与不变量的推理都会变得复杂得多。
sp22 原版 TypeScript 写法对照
sp22 用 TypeScript 讲授同一内容,机制与 Java 一一对应,注意以下三处差异:
// sp22 原版 TypeScript 写法:Tweet 的表示与不可变性
class Tweet {
private readonly author: string;
private readonly text: string;
private readonly timestamp: Date;
/**
* @param author Twitter user who wrote the tweet
* @param text text of the tweet
* @param timestamp date/time when the tweet was sent
*/
public constructor(author: string, text: string, timestamp: Date) {
this.author = author;
this.text = text;
this.timestamp = new Date(timestamp.getTime()); // 与 Java 相同的防御性拷贝
}
/** @returns date/time when the tweet was sent */
public getTimestamp(): Date {
return new Date(this.timestamp.getTime());
}
}
private readonly↔private final:TypeScript 的readonly保证字段在构造后不被重新赋值,与 Java 的final语义对应;两者都只锁引用,不锁被引用对象的可变内容,因此Date的防御性拷贝在两个版本里都必不可少。Array<T>↔List<T>:sp22 用const names: Array<string> = [...]展示静态检查表达的不变量;Java 对应写法是List<String> names = List.of("Huey", "Dewey", "Louie");(List.of返回不可变列表,但仅是运行时不可变)。- 严格 null 检查 ↔ 静态与断言双保险:sp22 在 6.031 的 TypeScript 配置中打开 strict null-checking,由静态类型检查器保证表示中不含
null/undefined;Java 没有对应机制,因此这些 null 检查必须由checkRep()中的assert s != null承担(这正是 Java 版多写几行断言的原因)。 Set<T>等可变容器:sp22 中Map<string, Set<string>>这样的表示同样可变,其「Safety from rep exposure」论证需要getFollowers()做防御性拷贝;Java 版对应写法是private final Map<String, Set<String>> followersOf,论证描述为「字段私有;String不可变;Set可变但getFollowers()返回全新的防御性拷贝;Map可变但从不作为参数或返回值出现」——注意这正是课程练习中唯一合格的论证方式:必须逐个字段说明,而不是写「所有字段都私有」了事。
与其他设计原则的关联
- Reading 06(Specifications,规格说明):本讲的
checkRep()与 null 检查直接延续「前置条件/后置条件隐含对象非 null」的约定;assert断言 RI 则是把规格中的前提转成可执行检查。 - Reading 07(Designing Specifications,设计规格):规格中的前置条件一旦太多太复杂,就应该改用一个 ADT 来封装(本讲
exclusiveOr的例子);这类封装的安全性建立在「AF/RI 只属于实现、规格只谈抽象值」的边界上。 - Reading 08(Mutability & Immutability,可变性与不可变性):本讲的防御性拷贝、不可变包装器(
Collections.unmodifiableList)、以及final只锁引用不锁内容的结论,全部是 Reading 08 的直接延伸;「有益的可变性」给出了不可变性的精确定义——抽象值不变,而非表示值不变。 - Reading 09(Avoiding Debugging,避免调试):
checkRep()是「让 bug 尽早暴露」这一策略的典范——因为伤害发生在离根因最近的地方,而不是在数据结构被污染很久之后。 - Reading 10(Abstract Data Types,抽象数据类型):本讲是 Reading 10 的理论深化。Reading 10 建立了抽象与表示的分离、表示独立性以及创造者/生产者/观察者/修改者的分类;本讲用 AF/RI 精确说明「表示如何被解释」,并给出了「如何证明不变量在所有实例上成立」的归纳规则。
MyString表示从「无冗余数组」换成「数组 + start/end」的例子,正是表示独立性带来的可修改性。 - Reading 12(Interfaces & Generics,接口与泛型):把 AF/RI 写清楚是给类型换实现的前提,而接口/泛型是让客户端只依赖抽象的手段;
Map<String, Set<String>>这类泛型容器出现在表示中时,其 Safety 论证的难度也随之上升。 - Reading 13(Debugging,调试):断言与
checkRep()是调试工具;RI 明确了「合法状态」,因此调试一个损坏的数据结构时,你可以先定位到第一次违反 RI 的位置。 - Reading 15(Equality,相等性):本讲为相等性打下理论基础——抽象函数是相等性的定义基础,因此不可变类型必须覆盖
equals(),从而也必须覆盖hashCode();toString()应当输出抽象值。RatNum是这条链路上本讲原文的主力例子;Duration、LetterSet也属同一链路,但它们是 Reading 15(相等性)中的例子,本讲只在需要对照时借用并已逐一标注。 - Reading 17(Recursive Data Types,递归数据类型):递归表示(树、列表)的 RI 通常需要递归地陈述(「左右子树都满足 RI」),而「建立与保持不变量」的证明相应地也就是对该递归结构做结构归纳。
- Reading 21(Concurrency,并发):本讲的可变表示在多线程共享时,RI 的保持会变得更困难;Reading 21 中记忆化缓存(memoization)这类「有益可变性」正是新的线程安全风险源——修改者的并发交错可能破坏缓存自身的表示不变量。
- Reading 23(Locks & Synchronization,锁与同步):把「修改者必须保持 RI」的推理扩展到并发场景,需要把操作的原子性一并纳入证明:只有互斥地执行修改者,RI 才能被保证。
关键要点
- 每个有实质表示的 ADT 都必须写下三件套:
Abstraction function(表示值 → 抽象值的映射,可代入求值)、Representation invariant(合法表示值的集合,可逐条断言)、Safety from rep exposure(逐个字段说明为什么客户端拿不到可变内部表示的别名)。三者缺一不可,并且写在字段声明旁边的普通注释里。 - 把 RI 变成可执行的
checkRep(),并在每个构造者、生产者、修改者返回前调用;观察者也建议调用。记住assert默认关闭,测试时必须用java -ea启动。 - 消灭表示暴露:凡参数或返回值中出现可变类型,就在边界处拷一份;能换成不可变类型(如用
java.time.ZonedDateTime代替java.util.Date)就优先更换;返回集合考虑不可变包装或拷贝。 - 一切相等性以抽象值为准:AF 是满射但不必单射,因此
equals()必须比较抽象值,hashCode()必须与之一致,toString()应当输出抽象值。 - 不变量由构造者与生产者确立、由修改者/观察者/生产者保持,且必须无表示暴露——这三条同时成立,不变量才对该 ADT 的所有实例成立;有益的可变性是允许的,只要抽象值不变。
常见陷阱与注意事项
- 把
private final当作「安全」 → 字段私有且 final 只锁引用;若类型可变(Date、数组、List)且引用被传入或返回,表示照样暴露,不可变性在不变量层面已经失效。 - 只在构造器里做防御性拷贝,忘了观察者也要拷贝 →
getTimestamp()这类方法把内部Date的别名交出去,客户端一个setHours()就能改坏你精心保护的表示。 - AF/RI 写成空泛的话(「所有字段都有效」「表示一个字符集合」) → 无法代入求值,
checkRep()无从编写,不同实现者对表示含义产生分歧,最终表现为「某人改了add,另一个人写的contains就悄悄错了」。 - 在 RI 里引用抽象值,或完全不写
checkRep()、写了却从不在-ea下运行 → 前者本末倒置(非法的表示值根本没有抽象值,RI 必须只谈表示本身,否则既不能判定也不能检查);后者让损坏的表示继续传播,bug 在离根因很远的地方爆发,因为assert在默认 JVM 配置下根本不会执行。 - 用表示值实现
equals()/hashCode()、覆盖equals()却忘记hashCode()、或把equals(RatNum that)当作覆盖equals(Object)→ 抽象值相同的对象被判为不等(或相等却哈希不同),放进HashSet/HashMap会出现找不到、重复存储等玄学 bug;签名写错则变成重载(overload),静态类型为Object的实参和静态类型为RatNum的实参会得到不同答案——务必写@Override(Reading 15 用equals(Duration that)演示了同一个错误)。 - 以为可变类型不需要担心表示暴露 → 可变类型的 RI 同样需要保护:
getFollowers()直接返回内部Set会让客户端绕过addFollower()的契约(例如让自己关注自己),一个操作层面的不变量就这样被破坏。正确做法是返回防御性拷贝或不可变包装。
思考题(带答案)
问题 1:RatNum 使用了「分母为正且已约分」的 RI,而课程原文指出也可以用更宽松的 RI(只要求分母非零)实现同一个 ADT。请说明这两种设计各自的代价,并解释为什么在宽松 RI 的版本中,sameValue() 不能像严格版本那样直接比较 numerator 与 denominator。
答案:严格 RI 的代价是每次构造(以及每次会产生新值的运算)都必须做一次 gcd 并调整符号,好处是表示被规范化成唯一形式,于是 equals、hashCode、toString 都很简单(直接比较/使用字段即可),而且可以顺手保证「分母永远为正」这一对客户端友好的性质。宽松 RI 的代价是同一个抽象值会有大量不同的表示(1/2、2/4、-3/-6……),于是任何「比较抽象值」的操作都必须真正去算抽象值:equals 要么交叉相乘 this.numerator * that.denominator == that.numerator * this.denominator(注意溢出风险,课程原文的 bigint 版本正是为此),要么在比较前先化简;好处是连续运算不必反复约分,可以在需要显示结果时才化简一次(课程原文的 toString() 就是这么做的,它甚至直接改写了 numerator/denominator 字段——因为改前改后的表示映射到同一个抽象值,所以这是无害的、有益的可变性)。这件事的教益是:AF 是不是单射取决于 RI 与表示的选取,而「比较抽象值」这条纪律在任何选取下都不能违反。
问题 2:本讲原文给 FollowGraph(可变类型,表示是 private final Map<String, Set<String>> followersOf)留了一道 Safety 论证的填空题,把 ..???.. 交给读者补全。请判断下面三种说法能否用来补全,并说明理由:(a)「String 是不可变的。」(b)「本类是可变类型,所以不存在表示暴露的问题。」(c)「followersOf 是可变 Map,其中装着可变的 Set 对象,但 getFollowers() 返回的是防御性拷贝,而其他所有参数与返回值都是不可变的 String 或 void。」然后回答一个更一般的问题:为什么「三个字段都是 private,所以不会发生表示暴露」这样的论证对 Tweet 也不合格,以及 timestamp 为什么必须出现在 Tweet 的 Safety 论证里(尽管 RI 没有对它提出额外条件)。
答案:三种说法都不能单独用来补全。(a) 只交代了表示中 String 这一部分——它完全没有触及 Set 与 Map 这两个可变类型,而它们才是暴露风险的所在;Safety 论证的要求是逐个字段交代,漏掉字段就等于漏掉风险。(b) 是错的,而且错在最容易误解的一点上:可变类型的不变量同样需要保护。FollowGraph 的 RI 里有「没有用户关注自己,即 x 不在 followersOf.get(x) 中」这样的条件,如果 getFollowers() 把内部 Set 直接交出去,客户端就能绕过 addFollower() 的契约往集合里塞进 x 自己,从而破坏这条 RI——可变性从来不是免于表示暴露的理由。(c) 是三者中唯一接近合格的表述:它逐字段说明(Map 可变但从不出现于参数或返回值;Set 可变但 getFollowers() 返回防御性拷贝),并覆盖了参数与返回值这两个暴露发生的边界。原文还给出两条可用的替代写法供对比:用 Collections.unmodifiableSet 之类的不可变包装把表示中的 Set 封起来并据此论证,或者逐操作列举(「构造器不暴露表示;addFollower 不暴露;removeFollower 不暴露;getFollowers 不暴露」)——但后者只有在逐个操作真的检查过、并说清依据时才成立,否则只是一句空话。最后,为什么同样的道理适用于 Tweet:「三个字段都是 private,所以不会发生表示暴露」只考察了字段可见性这一个维度,而暴露的真正判定标准是可变对象的引用有没有越过类边界。private 只阻止通过字段名直接访问,并不阻止你把内部可变对象的引用交出去:getTimestamp() 返回的 Date 与 t.timestamp 是同一个对象,客户端一次 setHours() 就改掉了内部状态;构造器若不拷贝,调用者手里的 Date 也仍然指向表示里那个对象。而 timestamp 必须出现的原因在于:整个类型的不变性依赖于所有字段都不被改动,即使 RI 对它没有额外条件(除了所有对象引用都隐含的 != null),只要它可能被外部改掉,Tweet 就不再是不可变的——「不可变」本身就是这个类型最重要的一条不变量。合格写法即原文那句:「All fields are private; author and text are Strings, so are guaranteed immutable; timestamp is a mutable Date, so Tweet() constructor and getTimestamp() make defensive copies to avoid sharing the rep’s Date object with clients.」
问题 3:请用「建立与保持不变量」的规则,说明为什么「观察者(observer)不能破坏 RI,除非它同时是修改者」,并解释表示暴露如何让整个证明失效。再以 MyIterator.next() 为例,说明一个同时是观察者与修改者的方法需要满足哪些证明义务。
答案:证明规则是结构归纳:构造者与生产者必须为新实例确立 RI;修改者、观察者与生产者必须为已有实例保持 RI;并且不得发生任何表示暴露。对纯观察者来说,证明义务几乎自动成立——它的方法体不写任何字段(按定义它只读取与返回信息),所以执行前后表示完全相同,RI 若进入时为真,退出时必然为真;它也可能通过调用其他观察者来间接读取,那些调用同样只读。唯一的例外是它同时是修改者(签名中带有「Modifies」或它调用了本类的修改者),此时它就必须像修改者一样证明「每个可被观察到的位置 RI 都仍然成立」。另一个例外情形是它把可变表示泄漏出去——严格说这时 RI 不是被观察者本身破坏的,而是被类外的代码破坏的,但后果一样:在 ADT 操作的边界之外,没有任何地方能断言 RI,于是归纳步中「所有改动都在某个操作的严格控制之下」这一前提不成立,整个证明随之作废;这正是「必须消灭表示暴露」成为证明规则第三条的原因。对 MyIterator.next() 而言,它的前置条件是 hasNext() == true(即进入时 index < list.size()),方法体做两件事:读取 list.get(index)(观察),然后 ++index(修改);它的证明义务是:在推进 index 之后,0 <= index <= list.size() 仍然成立(由前置条件 index < list.size() 与「index 每次只加一」共同保证),并在返回前用 checkRep() 断言这一点。此外,由于 next() 改变了迭代器的抽象值(它接下来将返回的元素序列变短了),这次修改必须在规格中如实声明为「Modifies」,而它不会破坏 RI,因为推进后的 index 恰好落在合法区间内。
