Reading 11: 抽象函数与表示不变量(Abstraction Functions & Rep Invariants)

目录 · ← l10 · l12 →

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 → booleanRI(r) 为真,当且仅当 r 是一个合法(良构,well-formed)的表示值,也即 r 位于抽象函数有定义的范围之内。等价的说法是:把 RI 看作表示值空间的一个子集——那些能映射到抽象值的表示值构成的子集。它的作用是把「内部状态在什么条件下才有意义」这一隐含知识显式化,并让损坏的数据结构尽早被抓住。
  • 直观解释(”它是什么?”):RI 就是这份表示空间的「入场券」规则,或者说表示值空间里的绿灯区与红灯区。以用字符串表示字符集合 CharSet 为例,若规定字符串中不得出现重复字符,则 RI("a") = trueRI("ac") = trueRI("acb") = true,而 RI("aa") = falseRI("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 就是解码规则。以 CharSetAF(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 写进代码:如果不同的实现者对表示的含义有分歧,这份表示就不再可靠;写下来是唯一可靠的沟通方式。

满射但不必单射(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/41/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() 返回的 Datet.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());——在输出关口复制,防止客户端反手改你的内部状态。
    • 返回新对象或不可变视图:能返回 StringInteger 或不可变类型就不要返回可变容器;必须返回集合时考虑拷贝或不可变包装。
    • 优先选择不可变类型:如果日期用的是不可变的 java.time.ZonedDateTime 而不是可变的 java.util.Date,那么讲完 private/public 这一节就可以结束了——不可能再有表示暴露。这是最省心的方案。
    • 防止可变对象被多个对象共享:把客户端传入的数组、MapList 直接存进表示,等于把表示的一部分放在客户端手里。一个同类型的例子见下面的 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;类型本身是否不可变;若可变,在哪里做了防御性拷贝或不可变包装;参数与返回值是否可能泄漏。注意 Tweettimestamp 没有额外的 RI 条件,但它仍然必须出现在 Safety 论证里,因为整个类型的不可变性依赖于所有字段都不被改动。
    • 写不清楚意味着什么:如果你写不出精确的 AF/RI,通常说明你对这个表示的语义自己也还没想清楚,或者表示设计本身就有问题;含糊的注释会让不同实现者产生分歧(本讲的 CharSet 练习「Trying to implement without an AF/RI」正是展示这种灾难:Louis Reasoner 没写下 AF/RI,于是三位队友各自揣着 SortedRepSortedRangeRepNoRepeatsRepAnyRep 四种不同理解去实现 add()/remove()/contains(),结果每种实现只对其中一部分 AF/RI 成立)。
    • 注释与代码要同步:修改表示时,AF/RI/Safety 三段注释是改动清单的第一项。

标准模板(必须完整书写,三行式注释缺一不可)

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」不充分(漏掉了可变的 SetMap);「本类是可变类型,所以不存在表示暴露问题」是错误的(可变类型的表示同样需要保护,否则客户端可以绕过 addFollower() 的契约、破坏「没有用户关注自己」这条 RI);「followersOf 从不出现在参数或返回值中」也要配合「getFollowers() 返回的 Set 做了什么处理」才成立。只有像「String 不可变;表示中的 Set 是可变类型,但 getFollowers() 返回的是全新的防御性拷贝而不是表示中任何集合的引用;表示中的 Map 是可变类型,但它从不出现在任何操作的参数或返回值中」这样逐字段、逐边界的表述,才构成完整而有力的论证。原文还提醒:Tweettimestamp 没有任何额外的 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: truecheckRep() 实际上没有业务断言(但隐含的 != null 仍值得断言);注意 int 这类原始类型字段不可能为 null,对它们写 assert i != null 是编译错误。
    • null 检查不该省:Java 中 RI 隐含要求每个对象引用非 null,因此 checkRep() 应包含这些 null 检查——「尽早抓住 null bug」正是它的价值(sp22 用 TypeScript 的严格 null 检查在静态层面承担了这一职责)。

有益的可变性(Beneficent Mutation)

  • 定义与目的:不可变的精确定义是「抽象值永不改变」,而不是「表示值永不改变」。既然 AF 是「多对一」的,实现完全可以在保持抽象值不变的前提下修改表示值——客户端观察不到任何差别。这种改动叫有益的可变性。它换来的往往是性能:缓存、数据结构再平衡、惰性清理。
  • 直观解释(”它是什么?”)RatNum 的弱 RI 版本是最经典的例子:RI 只要求 denominator != 0,于是连续的算术运算可以不约分;等到要给人类看结果时,toString() 才把 numeratordenominator 同时除以 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() 是修改者;要重复遍历就新建一个迭代器。
    • 修改者也要维护 RInext() 推进 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。
    • 类型名应当传达不变量(如 SortedCharSetUsername),让编译器替你守住一部分契约。
    • 为消除前置条件而抽取的 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()
}

【错误代码的问题】

  1. /*E*/ 处的 getAllSides() 把内部数组本身返回出去,于是客户端写 double[] s = t.getAllSides(); s[0] = 100; 就改掉了这个「不可变」三角形的边长——而 hypotenuse 已经固定,勾股关系这条不变量当场失效,且没有任何地方会报警。
  2. /*B*/ 处把 hypotenuse 声明为 public final 字段:客户端从此依赖具体表示(表示独立性被破坏),而 final 只保证这个字段不能被重新赋值,完全不保证表示可以被替换。
  3. 构造器只把两个 double 参数(原始类型,不存在别名问题)装进新数组,看起来「已经拷贝过了」,但一旦参数类型换成可变对象(数组、Date、集合),同样的写法就会把调用者的对象直接存进表示——这个陷阱被 /*D*/ 的写法掩盖了。
  4. 完全没有 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;
    }
}

【为什么这样更好】

  1. 表示中的数组不再有机会离开类:接受数组的构造器用 legs.clone() 切断入参别名,getAllSides() 返回 sides.clone() 切断出参别名,getSide(int) 返回原始类型 double,根本不产生别名。三条路径合起来才构成完整的 Safety 论证。
  2. 斜边不再作为字段存储,而是由两条直角边计算得出。这不只是省了一个字段:它把「勾股关系」这条原本需要被小心维护的不变量,变成了由构造方式自动成立的事实——破坏一条不可能被违反的规则,比事后检查它更可靠。
  3. AF / RI / Safety 三段注释与 checkRep() 齐备,且 checkRep() 逐条对应 RI,在每个公开方法返回前调用。
  4. scale()regularize() 这两个生产者都通过构造器产生新对象,因此新对象的 RI 由构造器负责建立——这正是「生产者必须确立新实例的不变量」。

【代码对比解说】 两个版本的字段声明都写得「很像不可变」:一个是 private double[],另一个是 public final double。问题恰恰在这里——final 锁住的是引用而不是数组内容,private 锁住的是字段名而不是你已经交出去的别名。错误版本在 /*E*/ 一处失守,整条不可变性就作废了;而且失守之后 hypotenuse 无法跟着变化,类的抽象值直接进入自相矛盾的状态。原文把这段代码标上 /*A*//*E*/ 五个位置让读者判断,按原文原则推理:/*B*/(公开字段让客户端依赖表示)与 /*E*/(返回内部数组威胁不可变性)确实成立;/*A*/(私有数组字段本身)只是「风险」而非暴露,暴露发生在引用外泄之时;/*C*/ 不成立——构造者完全可以有前置条件(本讲 Tweet 的构造器就有);/*D*/ 也不成立——legAlegBhypotenuse 都是原始类型 double,不存在别名,无需拷贝。最后一点尤其值得记住:判断是否需要防御性拷贝,看的是类型是否可变,而不是看这个字段是不是「重要」

【设计原则透视】 本组是 Safety from rep exposureRI 保持证明的交汇点。返回内部数组会让不变量在 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 的任何检查
}

【错误代码的问题】

  1. getTimestamp() 返回的 Datet.timestamp同一个对象。原文的客户端代码 Date d = t.getTimestamp(); d.setHours(d.getHours()+1); 是完全合理的写法,却顺手改掉了 t 内部的时间——Tweet 的不可变性不变量当场被破坏,而 Tweet 的代码一行都没被执行。
  2. 构造器直接保存传入的 Date,原文的 tweetEveryHourToday() 例子因此出错:它想用一个 Date 对象依次走过一天 24 小时、每小时造一条推文,但由于 24 个 Tweet 共享同一个 Date,最终所有推文的时间戳都相同
  3. 没有 Safety 论证,维护者无从知道 timestamp 需要拷贝。特别注意:timestamp 并没有额外的 RI 条件,但它仍然必须出现在 Safety 论证中,因为整个类型的不可变性依赖于所有字段都不被改动——省略它是最常见的错误。
  4. 没有 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();
    }
}

【为什么这样更好】

  1. 上面的三段注释就是课程原文给出的完整写法(RI 用 text.length <= 280 表述,在 Java 中即 text.length() <= 280):AF 说明「字段被解释成什么」,RI 说明「什么样的字段值合法」,Safety 逐字段交代隔离方式。
  2. 构造器与观察者各做一次防御性拷贝,于是 retweetLatertweetEveryHourToday 这两段「完全合理」的客户端代码再也不可能改坏内部表示。
  3. checkRep() 把 RI 的两条要求变成运行时可执行的断言,并在每个公开方法返回前调用;调用观察者也要检查,这样「由表示暴露引起的不变量破坏」会更早暴露。
  4. 更好的做法是把 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;
    }
}

【错误代码的问题】

  1. new RatNum(1, 2)new RatNum(2, 4) 的抽象值完全相同——AF(numerator, denominator) = numerator/denominator 把两者都映射到 1/2——但逐字段比较把它们判为不等。这与 AF 定义的相等性直接矛盾,也违反观察相等性:除了 getNumerator()/getDenominator() 这两个把表示细节暴露给客户端的观察者,没有任何规格内的操作能区分它们。
  2. hashCode() 同样基于表示值,于是这两个「本应相等」的对象哈希值不同,放进 HashSet/HashMap 后会分别落到不同的桶里——查找失败,而且不会有任何报错。
  3. 相等性的正确性被 RI 的强弱绑架:本版本刻意使用宽松 RI(原文明确说这是合理的设计,某些操作更便宜、某些更贵),而宽松 RI 恰恰意味着「同一抽象值有多种表示」,此时逐字段比较必然是错的。
  4. 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;
    }
}

【为什么这样更好】

  1. sameValue() 通过交叉相乘比较抽象值,因此 new RatNum(1, 2).equals(new RatNum(2, 4)) 返回 true——这正是 AF 定义的相等性;new RatNum(1, 2)new RatNum(2, 3) 仍然不等。
  2. hashCode() 先把表示化成最简形式再计算,保证「相等 ⇒ 哈希相同」,因此这些对象可以安全地作为 HashMap 的键或放进 HashSet
  3. toString() 输出抽象值(最简分式),于是同一个抽象值的不同表示打印结果一致——这也是检查 equals/hashCode 是否正确的一个实用手段。
  4. equals(Object)@Override 覆盖而不是重载,(RatNum) that 是类型转换,向编译器声明「经 instanceof 检查后我确信它是 RatNum」。

【代码对比解说】 两版代码的差别只在 sameValuehashCode 的几行,但这几行决定了「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;
    }
}

【错误代码的问题】

  1. new RatNum(2, 4) 违反 RI 的「已约分」条件——gcd(2, 4) = 2 ≠ 1。表示落进红灯区之后,AF 与 RI 给出的承诺(例如「表示唯一,可以逐字段比较」)全部失效,任何依赖规范形式的代码都会出错。
  2. new RatNum(1, 0) 构造出一个分母为 0 的对象:它的抽象值根本不是有理数,value() 返回 InfinitytoString() 打印 "1/0"。错误被制造出来时没有任何提示,直到很久以后在别处炸开。
  3. new RatNum(1, -2) 让分母为负,同样违反 RI;toString() 打出 "1/-2" 这种与 "-1/2" 表示同一抽象值却有不同写法的结果。
  4. 没有 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;
    }
}

【为什么这样更好】

  1. 构造器在返回前把表示规约到唯一的合法形式:先 gcd 约分,再统一符号使分母为正,d == 0 则立刻抛出异常——这就是「构造者必须确立不变量」的具体动作。
  2. checkRep() 逐条对应 RI(分母为正、已约分),并在每个构造器与每个观察者返回前调用;构造时若哪一步写错,断言会立刻指出问题出在这里。
  3. 表示被规范化之后,toString() 可以直接输出抽象值而不必再化简,equals/hashCode 也可以直接依赖规范形式(详见场景 3 的讨论)。
  4. 两个构造器(RatNum(int n)RatNum(int n, int d))都调用 checkRep(),因此「每个构造者都要建立 RI」这条要求没有漏网之鱼。

【代码对比解说】 这一组的问题不在观察者,而在构造者——也就是「不变量确立」这一环。原文的完整规则是:不变量必须由构造者与生产者确立由修改者、观察者与生产者保持,并且不发生表示暴露。错误版本把 RI 写得很清楚却完全不去建立它,于是每个对象从出生起就可能是非法的。同样的错误也会发生在修改者身上:例如一个用 char[] 表示字符集合的 CharSet(本讲原文的 CharSetString 表示,这里借用同一个抽象),其 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 被推进过头,这里会返回负数
    }
}

【错误代码的问题】

  1. 没有 RI 注释说明 index 的合法范围(0 <= index <= list.size()),也没有 checkRep(),于是 index 可以停留在非法状态(例如被推进到 list.size() 之外)而无人察觉。
  2. next() 没有声明前置条件「hasNext() 返回 true」,越界行为的契约含糊;客户端可以在空迭代器上调用它,得到的是底层 List 的实现细节异常,而不是类型的契约。
  3. remaining() 若不遵守 RI 就会返回负数,产生「逻辑上不可能」的观察结果——这是典型的 RI 破坏后静默传播错误的例子。
  4. 规格里没有写明 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;
    }
}

【为什么这样更好】

  1. RI 明确了 index 的合法范围,checkRep() 把它变成可执行断言;next() 修改表示后立即断言 RI,正是「修改者必须保持不变量」的落地方式。
  2. 规格里显式写出 next() 既是观察者(返回序列中的下一个元素)又是修改者(推进迭代器位置),并写出它的前置条件 hasNext() == true——hasNext()next() 的关系因此清晰:前者判断「还能不能再调用一次 next()」,后者要求这个判断为真。
  3. remaining() 在 RI 成立时不可能返回负数,因为 index <= list.size() 由不变量保证。
  4. 构造器对 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 readonlyprivate 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 是这条链路上本讲原文的主力例子;DurationLetterSet 也属同一链路,但它们是 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() 的契约(例如让自己关注自己),一个操作层面的不变量就这样被破坏。正确做法是返回防御性拷贝或不可变包装。

思考题(带答案)

问题 1RatNum 使用了「分母为正且已约分」的 RI,而课程原文指出也可以用更宽松的 RI(只要求分母非零)实现同一个 ADT。请说明这两种设计各自的代价,并解释为什么在宽松 RI 的版本中,sameValue() 不能像严格版本那样直接比较 numeratordenominator

答案:严格 RI 的代价是每次构造(以及每次会产生新值的运算)都必须做一次 gcd 并调整符号,好处是表示被规范化成唯一形式,于是 equalshashCodetoString 都很简单(直接比较/使用字段即可),而且可以顺手保证「分母永远为正」这一对客户端友好的性质。宽松 RI 的代价是同一个抽象值会有大量不同的表示1/22/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() 返回的是防御性拷贝,而其他所有参数与返回值都是不可变的 Stringvoid。」然后回答一个更一般的问题:为什么「三个字段都是 private,所以不会发生表示暴露」这样的论证对 Tweet 也不合格,以及 timestamp 为什么必须出现在 Tweet 的 Safety 论证里(尽管 RI 没有对它提出额外条件)。

答案:三种说法都不能单独用来补全。(a) 只交代了表示中 String 这一部分——它完全没有触及 SetMap 这两个可变类型,而它们才是暴露风险的所在;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() 返回的 Datet.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 恰好落在合法区间内。