Skip to content

换一种写法,改了哪些契约:局部变量、final 与不可变集合 ​

代码评审里有一条建议:「先 new 一个 ArrayList 再逐个 add,可以直接写成 List.of(...)」。改完以后,一个下游方法在检查 list.contains(null) 的时候抛了空指针异常。另一处把可变的局部变量加上了 final,大家以为这下数据不会被改了,结果对象里的列表照样被改了。

很多重构建议是「换一种更现代、更简洁的写法」:用字面量代替逐个添加,用 final 限制重新赋值,用 Optional 代替返回 null。这些改写多数时候是好的,它们减少了读者要追踪的状态。但每一种写法背后都有一套契约,新旧写法的契约并不相同。只看外观,就会在边界上改掉行为。

本文把几种常见的换写法逐一检查。实验见文末,在 JDK 25 上运行。

ArrayListList.ofArrays.asListStream.toListtoList 收集器add可以抛异常抛异常抛异常可以set抛异常可以放入 null可以构造时 NPE可以contains(null)falseNPE另外两条:unmodifiableList 是视图,源列表变它就变;List.copyOf 是快照final 与 record 都只管引用本身:final List 照样能 add,record 里的列表被外面改了它也跟着变空格表示实验没有测这一项
图 1 · 实测(JDK 25.0.4.1):把「new 一个 ArrayList 再逐个 add」改成一行 List.of,看起来只是换了写法,实际上换了一套契约。List.of 不能增删改,构造时拒绝 null,连 contains(null) 都抛异常;Stream.toList 同样不能改,却接受 null;Arrays.asList 能改元素不能改长度。

一、几种列表不是同一种东西 ​

text
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。

视图与快照 ​

text
源列表加入 b 之后:unmodifiableList 视图看到 [a, b],List.copyOf 快照仍是 [a]

Collections.unmodifiableList 返回的是一个只读的视图:调用方不能通过它修改,但源列表变了,它也跟着变。List.copyOf 返回的是一份快照,与源列表再无关系。对象把内部集合暴露给外面时,要明确给的是哪一种:返回视图,外面能看到之后的变化;返回快照,每次调用都复制一份。聚合对外暴露集合时的修改入口问题,见不变量要守住每一个入口。

二、final 管的是变量,不是对象 ​

text
final List 变量照样能 add:[x]
record 的字段是 final 的,构造后修改传入的列表:record 里看到 [apple, pear]

final 保证的是这个变量不会再指向另一个对象,不保证那个对象本身不变。record 的所有字段都是 final 的,但如果字段是一个可变列表,而构造时直接保存了调用方传进来的引用,调用方之后的修改就会出现在 record 里。

要让 record 真正持有一份不变的数据,在紧凑构造器里复制:

java
record Basket(List<String> lines) {
    Basket { lines = List.copyOf(lines); }
}

注意 List.copyOf 遇到 null 元素会抛异常,这同时也是一次校验。更多不可变与值语义的讨论,见实体与值对象与 Java 里的函数式设计。

三、声明与赋值:让编译器替你检查分支 ​

text
final int fee; if/else 两个分支各赋一次值:编译通过
只在 if 分支赋值:编译失败(variable fee might not have been initialized)
先给占位值 0 再按条件改:编译通过(漏掉的分支返回 0,编译器不再提醒)

先声明、在各个分支里赋值,这种写法常被批评为「声明与赋值分离」。但它和 final 一起使用时,编译器会检查每一条路径都赋了值,而且只赋了一次(Java 规范里的确定赋值规则)。漏掉一个分支,编译就失败。

真正有问题的是另一种写法:先给一个占位值,再在部分分支里修改。 int fee = 0; 之后,编译器认为它已经有值了,漏掉的分支会悄悄返回 0。这是很多「默认值掩盖了缺失分支」类缺陷的来源。

所以这里的建议不是「不要分离声明与赋值」,而是:

  • 能用一个表达式算出来的值,在声明处一次性得到(三元运算、switch 表达式、提取一个有名字的方法)。
  • 确实需要按分支赋值时,声明为 final 且不给初值,让编译器检查。
  • 不要为了「先有个值」而写占位初值。

四、var 与菱形一起用 ​

text
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 只是把「可能没有」写进了类型 ​

text
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()。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权