跳到内容

第 6 卷 / 第 2 章 / 第 2 课

2.2 不可变性、表示泄露与相等性 ​

封装并不等于把字段写成 private。只要调用者还能通过引用修改对象内部状态,表示就已经泄露。反过来,一个真正不可变的 ADT 必须同时控制构造输入、内部更新和返回值。

final 只固定引用 ​

本节使用 Java 21。前后两份 Playlist 是对比实现,应分别编译,或用后一份替换前一份,不要把两个同名类放进同一项目。第一份类看似不可变:字段是 private final,也没有 setter。

java
import java.util.List;

public final class Playlist {
    private final List<String> tracks;

    public Playlist(List<String> tracks) {
        this.tracks = tracks;
    }

    public List<String> tracks() {
        return tracks;
    }
}

下面是依赖 java.util.List、ArrayList 和上述 Playlist 的方法体片段。构造参数和返回值指向同一个可变列表:

java
List<String> source = new ArrayList<>(List.of("A"));
Playlist playlist = new Playlist(source);

source.add("B");             // 从构造入口修改内部状态
playlist.tracks().clear();   // 从访问器修改内部状态

执行 source.add 后 tracks 为 [A, B];再执行 clear,source 和 tracks 都为空。这两条修改都绕过了 Playlist 的业务方法。

final 只保证 tracks 字段以后不指向另一个列表,不保证列表内容不变。

在所有边界切断可变别名 ​

用以下完整类替换 Playlist.java。先复制再构造新值,使 append 的调用者明确拿到新对象:

java
import java.util.List;
import java.util.Objects;

public final class Playlist {
    private final List<String> tracks;

    public Playlist(List<String> tracks) {
        Objects.requireNonNull(tracks, "tracks");
        this.tracks = List.copyOf(tracks);
    }

    public List<String> tracks() {
        return tracks;
    }

    public Playlist append(String track) {
        Objects.requireNonNull(track, "track");
        var next = new java.util.ArrayList<>(tracks);
        next.add(track);
        return new Playlist(next);
    }
}

List.copyOf 返回不可修改快照并拒绝 null 元素;它可以复用合适的不可修改输入,不保证每次重新分配。这里的元素是不可变 String,所以访问器可直接返回列表。修改原 source 不再影响它;对 tracks().clear() 的调用会抛 UnsupportedOperationException。

不可变集合不等于深度不可变

如果列表元素本身可变,调用者仍可能通过元素引用改变可观察状态。深度不可变要求可达对象也不可变。若内部使用可变元素,至少要在输入、输出两端进行足够的复制并限制内部修改,才能保持 ADT 的可观察值不变;只复制列表容器远远不够。

Collections.unmodifiableList(source) 只提供不可修改视图;若别处仍持有 source,底层变化会透过视图出现。需要快照语义时,应复制而不是只包装。

不可变性的工程收益 ​

不可变对象通常更容易:

  • 维护 RI:构造完成后状态不再改变;
  • 安全共享:没有数据竞争导致的中间状态;
  • 作为 Map 键:哈希值不会因字段变化而漂移;
  • 测试和推理:同一个引用不会在远处被悄悄修改。

这不意味着所有对象都应不可变。连接、缓存、聚合根等天然包含状态变化。关键是把变化集中在明确边界内,并避免共享可变别名。

先决定“什么叫同一个” ​

实现 equals 前要先选择等价关系。

类型常见语义示例
值对象内容相同即相等金额、坐标、有理数
实体稳定标识相同即为同一实体订单、账户
进程内资源常使用对象身份线程、锁、连接句柄

不要根据“字段看起来差不多”自动生成相等性。例如两个订单即使收件人和总价相同,也不一定是同一订单。

equals 与 hashCode 是联合契约 ​

Java 的 equals 必须满足自反、对称、传递、一致,并对 null 返回 false。此外:

text
a.equals(b) == true  =>  a.hashCode() == b.hashCode()

反方向不成立;不同对象可以有相同哈希值。

下面的 Money 使用 Currency,是本节独立的值类型示例;若继续使用 1.1 的练习项目,应替换旧 Money.java,不能同时保留两个同名类型。record 生成基于组件的相等性,先决定是否把金额的 scale 视为值的一部分:

java
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        amount = amount.stripTrailingZeros();
    }
}

BigDecimal.equals 同时比较数值与 scale,所以原始 10.0 与 10.00 不相等,compareTo 却返回 0。这里选择忽略尾随零,规范化后的同币种 Money 相等且 hashCode 相同,放入 HashSet 只保留一个。stripTrailingZeros 可能产生负 scale,例如 10.0 变为 1E+1;展示格式应另行规定。BigDecimal 契约真实货币模型还要定义允许的小数位、舍入和跨币种运算规则;record 不会替你完成领域设计。

可变对象为什么不适合做哈希键 ​

将下面的完整示例保存为 HashKeyDemo.java,执行 javac --release 21 HashKeyDemo.java 和 java HashKeyDemo,预期依次输出 null、1:键仍占着位置,却按修改后的哈希找不到。该类型故意只用于展示可变键缺陷。

java
import java.util.HashMap;
import java.util.Map;

public final class HashKeyDemo {
    static final class MutableKey {
        private String code;
        MutableKey(String code) { this.code = code; }
        void setCode(String code) { this.code = code; }
        @Override public int hashCode() { return code.hashCode(); }
        @Override public boolean equals(Object other) {
            return other instanceof MutableKey key && code.equals(key.code);
        }
    }

    public static void main(String[] args) {
        Map<MutableKey, String> map = new HashMap<>();
        MutableKey key = new MutableKey("A");
        map.put(key, "value");
        key.setCode("B");
        System.out.println(map.get(key));
        System.out.println(map.size());
    }
}

只要参与 equals 或 hashCode 的字段在入表后变化,哈希容器的不变量就被破坏。更安全的做法是使用不可变键,或用稳定 ID 做键。

继承会让值相等更难 ​

若父类使用 instanceof,子类又增加参与相等性的字段,很容易破坏对称性:父类认为二者相等,子类却认为不等。值类型通常优先采用不可变组合和封闭实现;若确实需要可扩展层次,应把等价性写进公开规格,并用测试覆盖跨实现比较。

选择可变性与相等性的步骤 ​

  1. 判断类型是值、实体还是资源句柄;
  2. 写出调用者可观察的抽象值;
  3. 列出所有可变引用的进入和离开路径;
  4. 决定快照、只读视图还是受控修改语义;
  5. 若覆盖 equals,同时覆盖 hashCode;
  6. 测试等价律,并测试集合中的实际行为。

练习:审查一个时间段类型 ​

为 TimeRange 设计 ADT,要求区间为 [start, end):

  • start 与 end 应使用什么类型?
  • 空区间是否合法?
  • 怎样表达 contains 与 overlaps?
  • 两个区间什么时候相等?
  • 如果允许修改终点,哪些 RI、哈希和并发问题会随之出现?

一种可检查的选择是使用 Instant,要求 start <= end 并允许空区间。下面用比较关系表示伪代码,实际 Java 需调用 Instant 的比较方法:

text
isEmpty = start == end
contains(t) = start <= t && t < end
overlaps(other) = !isEmpty && !other.isEmpty
                  && start < other.end && other.start < end

例如 [1, 3) 与 [3, 5) 不重叠;[2, 2) 为空,与任何区间都不重叠。若直接套用两端交叉比较而漏掉空区间判断,后一个例子就可能被误判。若业务讨论日历日期,改用 LocalDate 并保留同样明确的边界。

先写规格、AF 和 RI,再决定字段。若顺序反过来,字段往往会意外变成公开契约。

参考资料 ​

Built with VitePress | Software Systems Atlas