换一种写法,改了哪些契约:局部变量、final 与不可变集合
代码评审里有一条建议:「先 new 一个 ArrayList 再逐个 add,可以直接写成 List.of(...)」。改完以后,一个下游方法在检查
list.contains(null)的时候抛了空指针异常。另一处把可变的局部变量加上了final,大家以为这下数据不会被改了,结果对象里的列表照样被改了。
很多重构建议是「换一种更现代、更简洁的写法」:用字面量代替逐个添加,用 final 限制重新赋值,用 Optional 代替返回 null。这些改写多数时候是好的,它们减少了读者要追踪的状态。但每一种写法背后都有一套契约,新旧写法的契约并不相同。只看外观,就会在边界上改掉行为。
本文把几种常见的换写法逐一检查。实验见文末,在 JDK 25 上运行。
一、几种列表不是同一种东西
add 一个元素:ArrayList true;List.of 抛出 UnsupportedOperationException;Arrays.asList 抛出 UnsupportedOperationException;Stream.toList() 抛出 UnsupportedOperationException;Collectors.toList() true
set(0, x):Arrays.asList a;List.of 抛出 UnsupportedOperationException
放入 null:ArrayList 2;List.of("a", null) 抛出 NullPointerException;Stream.of("a", null).toList() [a, null];List.copyOf(含 null 的列表) 抛出 NullPointerException
查询 contains(null):ArrayList false;List.of 抛出 NullPointerException几个容易踩到的差别:
List.of不仅拒绝放入 null,连contains(null)都抛异常。 开头那个下游方法就是这样出错的:它原本收到的是ArrayList,检查 null 返回 false;换成List.of之后,同样的检查抛了异常。Arrays.asList能改元素、不能改长度。 它是包装在数组上的视图,set会改到底层数组,add和remove抛异常。Stream.toList()不可修改,但接受 null。 与Collectors.toList()不同:后者在当前实现里返回可变的ArrayList(规范没有承诺返回类型)。把.collect(Collectors.toList())简化成.toList(),如果后面有代码往结果里add,就会抛异常。
把一种列表换成另一种之前,先查清楚调用方对它做了什么:会不会修改它,会不会放入或查询 null。
视图与快照
源列表加入 b 之后:unmodifiableList 视图看到 [a, b],List.copyOf 快照仍是 [a]Collections.unmodifiableList 返回的是一个只读的视图:调用方不能通过它修改,但源列表变了,它也跟着变。List.copyOf 返回的是一份快照,与源列表再无关系。对象把内部集合暴露给外面时,要明确给的是哪一种:返回视图,外面能看到之后的变化;返回快照,每次调用都复制一份。聚合对外暴露集合时的修改入口问题,见不变量要守住每一个入口。
二、final 管的是变量,不是对象
final List 变量照样能 add:[x]
record 的字段是 final 的,构造后修改传入的列表:record 里看到 [apple, pear]final 保证的是这个变量不会再指向另一个对象,不保证那个对象本身不变。record 的所有字段都是 final 的,但如果字段是一个可变列表,而构造时直接保存了调用方传进来的引用,调用方之后的修改就会出现在 record 里。
要让 record 真正持有一份不变的数据,在紧凑构造器里复制:
record Basket(List<String> lines) {
Basket { lines = List.copyOf(lines); }
}注意 List.copyOf 遇到 null 元素会抛异常,这同时也是一次校验。更多不可变与值语义的讨论,见实体与值对象与 Java 里的函数式设计。
三、声明与赋值:让编译器替你检查分支
final int fee; if/else 两个分支各赋一次值:编译通过
只在 if 分支赋值:编译失败(variable fee might not have been initialized)
先给占位值 0 再按条件改:编译通过(漏掉的分支返回 0,编译器不再提醒)先声明、在各个分支里赋值,这种写法常被批评为「声明与赋值分离」。但它和 final 一起使用时,编译器会检查每一条路径都赋了值,而且只赋了一次(Java 规范里的确定赋值规则)。漏掉一个分支,编译就失败。
真正有问题的是另一种写法:先给一个占位值,再在部分分支里修改。 int fee = 0; 之后,编译器认为它已经有值了,漏掉的分支会悄悄返回 0。这是很多「默认值掩盖了缺失分支」类缺陷的来源。
所以这里的建议不是「不要分离声明与赋值」,而是:
- 能用一个表达式算出来的值,在声明处一次性得到(三元运算、switch 表达式、提取一个有名字的方法)。
- 确实需要按分支赋值时,声明为
final且不给初值,让编译器检查。 - 不要为了「先有个值」而写占位初值。
四、var 与菱形一起用
var list = new ArrayList<>(); 推断出的类型:ArrayList<Object>:放进 1 和 "x" 都能编译,取出时 incompatible types: java.lang.Object cannot be converted to java.lang.String菱形 <> 从左边的声明类型推断泛型参数,var 从右边的表达式推断变量类型。两个同时用,就没有任何地方提供类型信息,结果是 ArrayList<Object>。什么都能放进去,取出来时才发现类型不对。用 var 时右边要写全泛型参数:var list = new ArrayList<String>();。
五、Optional 只是把「可能没有」写进了类型
Optional.empty().get():抛出 NoSuchElementException;Optional.of(null):抛出 NullPointerException;Optional.ofNullable(null).orElse("默认"):默认返回 Optional 让调用方在类型上就看到「这里可能没有值」,这是它的价值。但它不强制调用方处理:直接 get() 和直接用 null 一样会失败,只是异常换成了 NoSuchElementException。Optional.of(null) 本身就抛异常,可能为 null 的值要用 ofNullable。
Optional 适合用作返回值。用作字段、方法参数或集合元素,通常只是增加了一层包装,并没有让「可能没有」的处理变得更好。
六、检查清单
- 换列表实现之前,确认调用方会不会修改它、会不会放入或查询 null。
.collect(Collectors.toList())改成.toList()时,检查结果是否被修改。- 对外暴露集合时,明确给的是视图还是快照。
- record 和不可变对象在构造时复制可变的入参。
- 按分支赋值的变量声明为
final、不给初值;不写占位初值。 var的右边写全类型参数。Optional用在返回值上,不对它直接get()。
配套实验
- codesphere-labs/refactoring/local-state-semantics:JDK 25.0.4.1:五种列表的增删改与 null、视图与快照、final 变量与 record 字段、确定赋值检查、var 与菱形、Optional(验证记录)
参考资料