2.2 不可变性、表示泄露与相等性
工匠议事厅里,一件看似封装完好的组件被外部代码悄悄改坏;阿花开始检查引用到底泄露到了哪里。
封装并不等于把字段写成 private。只要调用者还能通过引用修改对象内部状态,表示就已经泄露。反过来,一个真正不可变的 ADT 必须同时控制构造输入、内部更新和返回值。
final 只固定引用
下面的类看似不可变:字段是 private final,也没有 setter。
public final class Playlist {
private final List<String> tracks;
public Playlist(List<String> tracks) {
this.tracks = tracks;
}
public List<String> tracks() {
return tracks;
}
}但构造参数和返回值都指向同一个可变列表:
List<String> source = new ArrayList<>(List.of("A"));
Playlist playlist = new Playlist(source);
source.add("B"); // 从构造入口修改内部状态
playlist.tracks().clear(); // 从访问器修改内部状态final 只保证 tracks 字段以后不指向另一个列表,不保证列表内容不变。
在所有边界切断可变别名
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 前要先选择等价关系。
| 类型 | 常见语义 | 示例 |
|---|---|---|
| 值对象 | 内容相同即相等 | 金额、坐标、有理数 |
| 实体 | 稳定标识相同即为同一实体 | 订单、账户 |
| 进程内资源 | 常使用对象身份 | 线程、锁、连接句柄 |
不要根据“字段看起来差不多”自动生成相等性。例如两个订单即使收件人和总价相同,也不一定是同一订单。
equals 与 hashCode 是联合契约
Java 的 equals 必须满足自反、对称、传递、一致,并对 null 返回 false。此外:
a.equals(b) == true => a.hashCode() == b.hashCode()反方向不成立;不同对象可以有相同哈希值。
值对象可以让 record 生成基于组件的相等性,但仍要先规范化输入:
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.0 和 10.00 具有相同组件。真实货币模型还要定义允许的小数位、舍入和跨币种运算规则;record 不会替你完成领域设计。
可变对象为什么不适合做哈希键
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("A");
map.put(key, "value");
key.setCode("B");
map.get(key); // 可能找不到:对象所在桶由旧 hash 决定只要参与 equals 或 hashCode 的字段在入表后变化,哈希容器的不变量就被破坏。更安全的做法是使用不可变键,或用稳定 ID 做键。
继承会让值相等更难
若父类使用 instanceof,子类又增加参与相等性的字段,很容易破坏对称性:父类认为二者相等,子类却认为不等。值类型通常优先采用不可变组合和封闭实现;若确实需要可扩展层次,应把等价性写进公开规格,并用测试覆盖跨实现比较。
选择可变性与相等性的步骤
- 判断类型是值、实体还是资源句柄;
- 写出调用者可观察的抽象值;
- 列出所有可变引用的进入和离开路径;
- 决定快照、只读视图还是受控修改语义;
- 若覆盖
equals,同时覆盖hashCode; - 测试等价律,并测试集合中的实际行为。
练习:审查一个时间段类型
为 TimeRange 设计 ADT,要求区间为 [start, end):
start与end应使用什么类型?- 空区间是否合法?
- 怎样表达
contains与overlaps? - 两个区间什么时候相等?
- 如果允许修改终点,哪些 RI、哈希和并发问题会随之出现?
先写规格、AF 和 RI,再决定字段。若顺序反过来,字段往往会意外变成公开契约。