CLASS 07 · 2026-02-25 · 抽象
抽象函数与表示不变量
本讲深入 ADT 的内部,学习两个核心概念:抽象函数(AF)把表示值映射到抽象值,表示不变量(RI)规定哪些表示值是合法的。它们是理解「不可变类型如何工作」的数学基础。
不变量
不变量(Invariant):在程序所有可能的运行时状态下都为真的性质。
不变量是 ADT 的「自我承诺」——无论使用者如何调用操作,ADT 始终维持这些性质。例如,一个表示日期的 ADT 应保证月份始终在 1 到 12 之间。
不可变性
不可变性是 6.102 中最重要的不变量之一。要保证不可变性,需要:
- 字段声明为
private readonly。 - 防御性复制:在构造函数和观察者中,对可变输入/输出进行复制,防止外部通过别名修改内部状态。
class ImmutablePerson {
private readonly name: string;
private readonly readonlyHobbies: ReadonlyArray<string>;
constructor(name: string, hobbies: string[]) {
this.name = name;
// 防御性复制:保存一份不可变的快照
this.readonlyHobbies = [...hobbies];
}
getHobbies(): ReadonlyArray<string> {
return this.readonlyHobbies; // 返回只读视图,外部无法修改
}
}
表示不变量与抽象函数
ADT 内部存在两个世界:
- 表示空间 \(R\):内部实际的存储结构(私有字段)。
- 抽象空间 \(A\):使用者看到的抽象值。
抽象函数(Abstraction Function)\(\text{AF}: R \to A\) 把每个表示值映射到它代表的抽象值。
表示不变量(Rep Invariant)\(\text{RI}: R \to \text{boolean}\) 判断一个表示值是否合法(well-formed)。
只有满足 RI 的表示值才是有效的,AF 只在这些有效值上有定义。
// 用一对数表示有理数
class Rational {
private readonly numer: number; // 分子
private readonly denom: number; // 分母
// RI: denom > 0 且 gcd(|numer|, denom) == 1(已约分)
// AF: AF(numer, denom) = numer / denom
constructor(numer: number, denom: number) {
if (denom === 0) throw new Error("denominator cannot be zero");
const g = gcd(Math.abs(numer), denom);
this.numer = numer / g;
this.denom = denom / g;
}
}
检查表示不变量
实现一个 checkRep() 方法,用断言检查 RI 是否成立。在每个操作(构造者、生产者、修改者)的末尾调用它,可以在开发阶段及早发现不变量被破坏的情况。
private checkRep(): void {
assert(this.denom > 0);
assert(gcd(Math.abs(this.numer), this.denom) === 1);
}
良性副作用
实现可以在内部修改表示值,只要修改后它仍然映射到同一个抽象值。这种修改称为良性副作用(benevolent side-effect)。例如,缓存一个计算结果不会改变抽象值,只是提升性能。
文档化 AF、RI 与表示泄露的安全性
ADT 的实现者应写下:
- 抽象函数的具体定义。
- 表示不变量的具体条件。
- 表示泄露的安全性论证:说明为什么外部代码无法通过别名破坏不变量。
ADT 不变量替代前置条件
设计良好的 ADT 会自行封装和强制不变量,而不是把检查推给调用者。例如,Rational 的构造函数自动约分并检查分母非零,调用者无需自己保证这些条件。
如何建立不变量
- 在初始状态(构造函数)中使不变量为真。
- 确保所有改变状态的操作(生产者、修改者)都保持不变量为真。
对于不可变类型,只需检查第 1 步——因为对象一旦创建就不会改变。
核心要点
AF抽象函数 AF: R → A 把表示值映射到抽象值。
RI表示不变量 RI: R → boolean 判断表示值是否合法。
表示泄露通过别名暴露内部表示会破坏表示独立性和不变量。