2.1 ADT 的规格、表示与不变式
抽象数据类型(Abstract Data Type,ADT)首先是一份行为规格:它定义一组可能的值,以及调用者可以执行的操作。类、接口、结构体只是实现这份规格的语言工具。
这一节要解决四个问题:
- 抽象值是什么;
- 每个操作承诺什么;
- 内部字段如何映射为抽象值;
- 哪些内部状态永远不允许出现。
从抽象值开始,而不是从字段开始
以有理数为例。调用者关心的是数学值 (1, 2)、(2, 4),还是一段字符串。
可以先写出公开规格:
类型:Rational
抽象值:任意有理数
of(numerator, denominator)
前置条件:数值对象非 null,denominator != 0
失败:null 抛 NullPointerException;零分母抛 IllegalArgumentException
后置条件:返回 numerator / denominator 对应的有理数
add(other)
前置条件:other 非 null;否则抛 NullPointerException
后置条件:返回 this + other;不修改任一操作数
numerator(), denominator()
后置条件:返回最简分数,且 denominator > 0规格没有承诺字段布局,也没有要求调用者理解约分算法。这条边界允许实现演进而不迫使调用者一起修改。
抽象函数与表示不变式
实现者需要两条内部说明。
**抽象函数(Abstraction Function,AF)**解释字段怎样对应抽象值:
AF(numerator, denominator) = numerator / denominator**表示不变式(Representation Invariant,RI)**限定合法内部状态:
numerator、denominator 均非 null
denominator > 0
gcd(abs(numerator), denominator) = 1
numerator = 0 时 denominator = 1AF 回答“它表示什么”,RI 回答“哪些表示允许存在”。二者用途不同,不能合并成一句“字段必须正确”。
一个完整的不可变实现
沿用 1.1 节 Java 21/Maven/JUnit 练习环境,把完整类保存为 src/main/java/Rational.java。这里使用 BigInteger 避免 long 交叉相乘时静默溢出;数学规格允许任意有理数,实际可表示范围仍受实现和内存限制。乘法与 gcd 的成本随位数增长,不能把大整数当成固定成本的机器整数。
import java.math.BigInteger;
import java.util.Objects;
public final class Rational {
private final BigInteger numerator;
private final BigInteger denominator;
private Rational(BigInteger numerator, BigInteger denominator) {
this.numerator = numerator;
this.denominator = denominator;
checkRep();
}
public static Rational of(long numerator, long denominator) {
return of(BigInteger.valueOf(numerator), BigInteger.valueOf(denominator));
}
public static Rational of(BigInteger numerator, BigInteger denominator) {
Objects.requireNonNull(numerator, "numerator");
Objects.requireNonNull(denominator, "denominator");
if (denominator.signum() == 0) {
throw new IllegalArgumentException("denominator is zero");
}
if (numerator.signum() == 0) {
return new Rational(BigInteger.ZERO, BigInteger.ONE);
}
BigInteger gcd = numerator.gcd(denominator);
BigInteger n = numerator.divide(gcd);
BigInteger d = denominator.divide(gcd);
if (d.signum() < 0) {
n = n.negate();
d = d.negate();
}
return new Rational(n, d);
}
public Rational add(Rational other) {
Objects.requireNonNull(other, "other");
BigInteger n = numerator.multiply(other.denominator)
.add(other.numerator.multiply(denominator));
BigInteger d = denominator.multiply(other.denominator);
return of(n, d);
}
public BigInteger numerator() {
return numerator;
}
public BigInteger denominator() {
return denominator;
}
private void checkRep() {
assert denominator.signum() > 0;
assert numerator.gcd(denominator).equals(BigInteger.ONE);
assert numerator.signum() != 0 || denominator.equals(BigInteger.ONE);
}
@Override
public boolean equals(Object obj) {
return this == obj
|| obj instanceof Rational other
&& numerator.equals(other.numerator)
&& denominator.equals(other.denominator);
}
@Override
public int hashCode() {
return Objects.hash(numerator, denominator);
}
@Override
public String toString() {
return denominator.equals(BigInteger.ONE)
? numerator.toString()
: numerator + "/" + denominator;
}
}所有实例都从工厂进入,并在进入对象前完成规范化。对象只保存不可变 BigInteger,构造后也没有修改入口,因此 RI 不会被普通内部更新破坏。
assert 的职责边界
分母为零是外部输入错误,必须显式抛出异常。checkRep() 检查的是实现内部假设,适合测试和调试;Java 断言可能未启用,不能承担公开 API 的输入验证。
表示独立不是“使用了接口”
表示独立(representation independence)意味着:只要两个实现都满足同一公开规格,调用者就不应依赖它们的内部表示。
这里比较的是同一个公开 Rational 类型更换内部实现,不是保证两个任意 Java 类自动具有跨类相等性。
假设以后改为保存未约分但分母非零的 (numerator, denominator),在读取或比较时再约分,同时保持 hashCode 与值相等一致。只要公开方法的结果、异常和可观察副作用仍符合规格,调用代码就不需要变化。反过来,即使类实现了接口,只要调用者要做向下转型、读取具体字段或判断具体实现类型,表示独立就已经被破坏。
用契约测试约束不同实现
把以下测试保存为 src/test/java/RationalTest.java,运行 mvn -Dtest=RationalTest -DenableAssertions=true test;应发现并通过 5 个测试。断言开关用于启用内部 checkRep,公开输入检查即使关闭断言也必须有效。测试围绕抽象行为和公开访问器:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class RationalTest {
@Test
void normalizesEquivalentRepresentations() {
assertEquals(Rational.of(1, 2), Rational.of(2, 4));
assertEquals(Rational.of(-1, 2), Rational.of(1, -2));
assertEquals(Rational.of(0, 1), Rational.of(0, -9));
}
@Test
void exposesCanonicalComponentsAndMatchingHashes() {
var value = Rational.of(2, -4);
assertEquals(java.math.BigInteger.valueOf(-1), value.numerator());
assertEquals(java.math.BigInteger.valueOf(2), value.denominator());
assertEquals(Rational.of(-1, 2).hashCode(), value.hashCode());
}
@Test
void rejectsInvalidInputs() {
assertThrows(IllegalArgumentException.class, () -> Rational.of(0, 0));
assertThrows(NullPointerException.class, () ->
Rational.of(null, java.math.BigInteger.ONE));
assertThrows(NullPointerException.class, () -> Rational.of(1, 2).add(null));
}
@Test
void normalizesLongMinValueWithoutOverflow() {
var value = Rational.of(1, Long.MIN_VALUE);
assertEquals(java.math.BigInteger.valueOf(-1), value.numerator());
assertEquals(java.math.BigInteger.ONE.shiftLeft(63), value.denominator());
}
@Test
void additionPreservesValueAndOperands() {
Rational left = Rational.of(1, 3);
Rational right = Rational.of(1, 6);
assertEquals(Rational.of(1, 2), left.add(right));
assertEquals(Rational.of(1, 3), left);
assertEquals(Rational.of(1, 6), right);
}
}若一个测试只有读取私有字段才能成立,它大概率在验证实现,而不是 ADT 的契约。
规格设计检查表
设计一个 ADT 时,按顺序回答:
- 抽象值集合是什么?
- 操作是否足以完成该类型承诺的任务?
- 前置条件能否通过更好的类型或工厂消除?
- 每个操作会不会修改自身、参数或外部状态?
- 失败时抛什么,状态是否保持不变?
- AF 与 RI 能否覆盖每个字段和每种构造路径?
下一节继续处理最容易穿透抽象边界的三个问题:可变性、表示泄露与相等性。