跳到内容

第 6 卷 / 第 2 章 / 第 1 课

2.1 ADT 的规格、表示与不变式 ​

抽象数据类型(Abstract Data Type,ADT)首先是一份行为规格:它定义一组可能的值,以及调用者可以执行的操作。类、接口、结构体只是实现这份规格的语言工具。

这一节要解决四个问题:

  • 抽象值是什么;
  • 每个操作承诺什么;
  • 内部字段如何映射为抽象值;
  • 哪些内部状态永远不允许出现。

从抽象值开始,而不是从字段开始 ​

以有理数为例。调用者关心的是数学值 p/q,并不关心对象内部保存的是 (1, 2)、(2, 4),还是一段字符串。

可以先写出公开规格:

text
类型: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)**解释字段怎样对应抽象值:

text
AF(numerator, denominator) = numerator / denominator

**表示不变式(Representation Invariant,RI)**限定合法内部状态:

text
numerator、denominator 均非 null
denominator > 0
gcd(abs(numerator), denominator) = 1
numerator = 0 时 denominator = 1

AF 回答“它表示什么”,RI 回答“哪些表示允许存在”。二者用途不同,不能合并成一句“字段必须正确”。

一个完整的不可变实现 ​

沿用 1.1 节 Java 21/Maven/JUnit 练习环境,把完整类保存为 src/main/java/Rational.java。这里使用 BigInteger 避免 long 交叉相乘时静默溢出;数学规格允许任意有理数,实际可表示范围仍受实现和内存限制。乘法与 gcd 的成本随位数增长,不能把大整数当成固定成本的机器整数。

java
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,公开输入检查即使关闭断言也必须有效。测试围绕抽象行为和公开访问器:

java
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 时,按顺序回答:

  1. 抽象值集合是什么?
  2. 操作是否足以完成该类型承诺的任务?
  3. 前置条件能否通过更好的类型或工厂消除?
  4. 每个操作会不会修改自身、参数或外部状态?
  5. 失败时抛什么,状态是否保持不变?
  6. AF 与 RI 能否覆盖每个字段和每种构造路径?

下一节继续处理最容易穿透抽象边界的三个问题:可变性、表示泄露与相等性。

参考资料 ​

Built with VitePress | Software Systems Atlas