跳到内容

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

工匠议事厅里,一件看似封装完好的组件被外部代码悄悄改坏;阿花开始检查引用到底泄露到了哪里。

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

final 只固定引用

下面的类看似不可变:字段是 private final,也没有 setter。

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

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

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

但构造参数和返回值都指向同一个可变列表:

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

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

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

在所有边界切断可变别名

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 元素。因为构造时已切断与原列表的别名,访问器可以直接返回内部引用;调用者仍不能修改它。

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

如果列表元素本身可变,调用者仍可能通过元素引用改变可观察状态。深度不可变要求对象图中所有可达对象都不可变,或在边界进行足够深的复制。

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

不可变性的工程收益

不可变对象通常更容易:

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

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

先决定“什么叫同一个”

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

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

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

equalshashCode 是联合契约

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

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

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

值对象可以让 record 生成基于组件的相等性,但仍要先规范化输入:

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();
    }
}

这里的规范化让 10.010.00 具有相同组件。真实货币模型还要定义允许的小数位、舍入和跨币种运算规则;record 不会替你完成领域设计。

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

java
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("A");
map.put(key, "value");

key.setCode("B");
map.get(key); // 可能找不到:对象所在桶由旧 hash 决定

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

继承会让值相等更难

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

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

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

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

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

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

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

参考资料

Built with VitePress | Software Systems Atlas