Java里有一个很有意思的问题:
两个对象明明都是List,为什么一个能add,另一个却不让你add?
Java
List<String> list = List.of("A", "B");
list.add("C");
代码能编译,但运行时直接抛出UnsupportedOperationException。
如果把List.of()换成ArrayList,同样的代码却能正常运行。
这就有点奇怪了:既然它们都是List,为什么不能直接替换?
这正好可以用里氏替换原则来说明。
里氏替换原则
里氏替换原则的大意是:凡是使用基类的地方,都能透明地替换成它的子类,程序行为不变。
这个表述放到Java里,可以这么来描述:一段代码只依赖父类型,把实例换成任意一个子类,这段代码的运行结果不变。
注意这里的重点。判断替换是否成立,看的不是类型声明,不是继承关系图,而是行为。编译器认识List list = new LinkedList<>();这种写法,它不会管你换成哪个实现。真正决定替换是否安全的,是运行期间这段代码的每一次方法调用、每一个返回值、每一次异常,是否跟之前完全一致。
下面我们写段代码来验证一下。
契约
开始写代码之前,需要先回答一个问题:一段只依赖父类型的代码,到底在依赖什么?
答案是依赖接口承诺过的行为。拿java.util.List举例,它的javadoc里写了一堆承诺:调用add之后元素数量增加,get(0)能取回第一个加入的元素,remove之后数量减一,迭代顺序和插入顺序一致。这些承诺写进接口文档的那一刻,就变成了所有实现类必须共同遵守的契约。这里有个细节要留意:javadoc给add、remove这类修改操作留了个口子,把它们标记为可选操作,实现类可以选择不支持。这个口子和后面出现的失败直接相关。
也就是说,契约其实有两层。一层是文档里明确写出来的,另一层是调用方根据类型,对它应该具备哪些能力形成的预期。程序员看到List这个词,默认它是可以增删元素的。后一层预期不写在任何文档里,但真实地存在于业务代码里。两层契约对不上的时候,问题就来了。
完整测试程序如下。这个verify函数模拟的是业务代码里最常见的场景:拿到一个List,默认它是可变的。带着这个预期去检验替换是否安全:
Java
public static void main(String[] args) {
verify("ArrayList", new ArrayList<>());
verify("LinkedList", new LinkedList<>());
verify("Arrays.asList", Arrays.asList());
verify("List.of", List.of());
verify("Collections.unmodifiableList", Collections.unmodifiableList(new ArrayList<>()));
}
verify函数只接收List接口,参数类型没有出现任何具体类,函数体里只用接口上声明过的方法:
Java
static void verify(String name, List<String> list) {
try {
// 契约1:add之后,元素数量按预期增长
list.add("a");
list.add("b");
if (list.size() != 2) {
System.out.println(name + " : FAIL,add后size不是2,实际是" + list.size());
return;
}
// 契约2:get能取回第一个加入的元素
if (!"a".equals(list.get(0))) {
System.out.println(name + " : FAIL,get(0)没有返回第一个加入的元素");
return;
}
// 契约3:迭代顺序和插入顺序一致
String firstInLoop = null;
for (String s : list) {
firstInLoop = s;
break;
}
if (!"a".equals(firstInLoop)) {
System.out.println(name + " : FAIL,迭代出的第一个元素不对");
return;
}
// 契约4:remove之后,元素数量减一
list.remove(0);
if (list.size() != 1) {
System.out.println(name + " : FAIL,remove后size不是1,实际是" + list.size());
return;
}
System.out.println(name + " : PASS,四条契约全部满足");
} catch (UnsupportedOperationException e) {
System.out.println(name + " : FAIL,抛出" + e);
}
}
四条契约全部兑现就通过,任何一条出问题就失败。
在Windows 11里的运行结果
在Java 17下编译运行,输出如下:
text
ArrayList : PASS,四条契约全部满足
LinkedList : PASS,四条契约全部满足
Arrays.asList : FAIL,抛出java.lang.UnsupportedOperationException
List.of : FAIL,抛出java.lang.UnsupportedOperationException
Collections.unmodifiableList : FAIL,抛出java.lang.UnsupportedOperationException
五个List实现,只有ArrayList和LinkedList通过了测试,另外三个都在第一条契约,也就是add这里失败了。
也就是说,虽然它们都实现了List,但对调用方来说,它们并不能完全互换。
这正是里氏替换原则要解决的问题:如果代码依赖的是List,那么换成另一个List实现后,原来的代码至少应该继续按调用方预期的方式工作。
显然这里没有做到。这三个实现没有违背javadoc的字面规定,它们违背的是调用方对List的预期,也就是前面说的第二层契约。
更有意思的是,造成这种差异的并不是什么第三方库,而是Java JDK自己提供的几个List实现。
JDK为什么允许这种情况存在
看看JDK 17里Collection接口的Javadoc,就能找到答案。前面说过的那个口子就在这里:集合接口中的一些修改操作,被定义成了optional operation(可选操作)。接口虽然提供了add、remove这些方法,但具体实现类可以选择不支持,遇到不支持的操作时,可以抛出UnsupportedOperationException。
这其实是JDK对集合接口做出的一个妥协:接口把方法统一了,但没有要求所有实现都必须支持这些方法。
比如Arrays.asList返回的List,本质上是对原数组的一层包装。数组长度本来就是固定的,所以可以修改某个元素,但不能增加或删除元素。
List.of更简单,它返回的就是不可变的List。Collections.unmodifiableList也是类似的思路,只不过它是在原有List外面套了一层只读包装。
可能有人会说:既然List的Javadoc已经明确写了,某些实现可以不支持add,那么List.of抛出UnsupportedOperationException就没什么问题,也不能说它违反了里氏替换原则。
从Javadoc的角度看,这个说法确实没错。List.of没有违反接口文档里写明的规则。
但实际写代码时,不能只看这一层。调用方拿到一个List,能看到的只有类型,这个List能不能修改,并不写在类型上,只能调用了才知道。
问题就出在这种状态上:**同样是List,不同实现却有完全不同的使用限制。**List同时容纳了可变和不可变两种实现,替换实现的时候,编译阶段给不出任何信号。
所以,这个问题不能简单归结为List.of的问题。真正的问题是List这个接口本身定义得比较宽,把可变和不可变两种实现放到了同一个接口下面。
JDK其实也知道这个问题,只是List这个老接口已经存在很多年,不可能为了这个问题直接改掉。
所以到了JDK 9,JDK增加了List.of,让创建不可变列表变得简单很多。以前可能要写:
Java
Collections.unmodifiableList(Arrays.asList("A","B"));
现在直接:
Java
List.of("A","B");
写法确实简单了。
但要注意,List.of返回的还是List。它并没有改变List接口本身的设计,调用add依然会抛UnsupportedOperationException。
所以,List.of解决的是创建不可变List太麻烦 的问题,并没有解决可变List和不可变List都叫List这个问题。
里氏替换失效的坑也不只存在于List。实际开发里,还有几种比较典型的情况。
第一种,是子类增加了额外限制。
父类原本允许你传入任意对象,子类却要求对象必须满足某个额外条件。
比如HashSet基本不关心元素有没有实现Comparable,但TreeSet需要比较元素的大小。如果往TreeSet里放无法比较的对象,运行时就可能抛出ClassCastException。同样一段代码,换成HashSet却可以正常运行。
第二种,是子类直接不支持父类提供的某个操作。
这就是前面Arrays.asList、List.of和Collections.unmodifiableList的情况。方法在接口里存在,编译也能通过,但真正调用时才发现这个实现不支持,于是抛出异常。
第三种,更隐蔽:继承破坏了子类自己的内部约束。
这种情况反而更容易让人踩坑,因为代码表面上看不出问题。
Properties是个典型。它内部有一条约束(不变量):键和值都必须是字符串。但Java的单继承机制让它无法拒绝父类Hashtable的put(Object, Object)方法,只能把这个方法继承过来。按照父类的契约,put接受任意Object作为键和值,Properties也确实照单全收,调用时不报任何错,非字符串的数据就这样进来了,内部约束被打破。一旦调用store把内容写入文件,只要里面有非字符串的键,就会抛出ClassCastException。错误被推迟到了后面的操作上,put的调用现场拿不到任何信号。JDK的javadoc也承认,这种用法强烈不推荐。这才是最贴合里氏替换原则本义的违反:每一步都符合父类契约,组合起来却不行。
所以,真正需要注意的并不只是有没有抛异常,而是:一个实现替换另一个实现之后,原来的代码还能不能按照原来的方式正常工作。
Spring里也有同样的困境
这不是集合框架独有的问题。Spring的Resource接口是另一个例子。
Resource想统一抽象各种来源的资源:文件系统的文件、类路径下的资源、网络流。它定义了一个getFile方法。问题在于,不是所有资源都能解析成一个文件。类路径资源打包进jar之后,它只是压缩包里的一个条目,不存在文件系统上的绝对路径。
Spring在AbstractResource这个基类里的做法,是让getFile的默认实现直接抛出FileNotFoundException:
Java
public File getFile() throws IOException {
// 无法解析为文件系统中的绝对路径
throw new FileNotFoundException(getDescription()
+ " cannot be resolved to absolute file path");
}
一段面向Resource接口写、调用了getFile的代码,传进FileSystemResource一切正常,传进jar包里的ClassPathResource就会在运行期报错。结构和List的例子一模一样。
Spring的应对方式值得借鉴。它在Resource接口上加了一个isFile方法,调用方在调用getFile之前,先用它探测一下这个资源到底能不能以文件形式访问。接口上有一个方法不是所有实现都能兑现,就配套提供一个能力探测方法,把运行期的意外提前变成调用方的显式判断。
小结
我们自己设计的时候,可以问如下2个问题:
- 接口文档里的每条承诺,是不是所有实现都兑现得了?
- 兑现不了的地方,有没有提供探测方法,让调用方能提前判断?
2个问题都答得上来,这个接口的继承体系基本是健康的。
如果答不上来,那你的接口就正在给未来的某一天埋雷。只不过这一次,踩到雷的人不是写实现类的人,而是那个信任了你的接口签名的调用者。