2.2 不可变性、表示泄露与相等性
封装并不等于把字段写成 private。只要调用者还能通过引用修改对象内部状态,表示就已经泄露。反过来,一个真正不可变的 ADT 必须同时控制构造输入、内部更新和返回值。
final 只固定引用
本节使用 Java 21。前后两份 Playlist 是对比实现,应分别编译,或用后一份替换前一份,不要把两个同名类放进同一项目。第一份类看似不可变:字段是 private final,也没有 setter。
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 的方法体片段。构造参数和返回值指向同一个可变列表:
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 的调用者明确拿到新对象:
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。此外:
a.equals(b) == true => a.hashCode() == b.hashCode()反方向不成立;不同对象可以有相同哈希值。
下面的 Money 使用 Currency,是本节独立的值类型示例;若继续使用 1.1 的练习项目,应替换旧 Money.java,不能同时保留两个同名类型。record 生成基于组件的相等性,先决定是否把金额的 scale 视为值的一部分:
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:键仍占着位置,却按修改后的哈希找不到。该类型故意只用于展示可变键缺陷。
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,子类又增加参与相等性的字段,很容易破坏对称性:父类认为二者相等,子类却认为不等。值类型通常优先采用不可变组合和封闭实现;若确实需要可扩展层次,应把等价性写进公开规格,并用测试覆盖跨实现比较。
选择可变性与相等性的步骤
- 判断类型是值、实体还是资源句柄;
- 写出调用者可观察的抽象值;
- 列出所有可变引用的进入和离开路径;
- 决定快照、只读视图还是受控修改语义;
- 若覆盖
equals,同时覆盖hashCode; - 测试等价律,并测试集合中的实际行为。
练习:审查一个时间段类型
为 TimeRange 设计 ADT,要求区间为 [start, end):
start与end应使用什么类型?- 空区间是否合法?
- 怎样表达
contains与overlaps? - 两个区间什么时候相等?
- 如果允许修改终点,哪些 RI、哈希和并发问题会随之出现?
一种可检查的选择是使用 Instant,要求 start <= end 并允许空区间。下面用比较关系表示伪代码,实际 Java 需调用 Instant 的比较方法:
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,再决定字段。若顺序反过来,字段往往会意外变成公开契约。