在Java的类型系统中,数组有什么缺陷吗?

2020年2月,王垠吐槽了下Java的类型系统,说:

关于程序员对 Java 类型系统的理解,比较高级的一个面试问题是这样:

王垠原版的代码
1
2
3
4
5
6
public static void f() {
String[] a = new String[2];
Object[] b = a;
a[0] = "hi";
b[1] = Integer.valueOf(42);
}

这段代码里面到底哪一行错了?为什么?如果某个 Java 版本能顺利运行这段代码,那么如何让这个错误暴露得更致命一些?
注意这里所谓的「错了」是本质上,原理上的。

那么这儿的“错误”是指什么呢?

TL;DR

如果只能用一句话回答这个问题的话,那么就是:

Java数组不支持泛型,破坏了Java的类型安全性

类型系统的一些前提

一个好的类型系统,能够尽可能早的检测出错误,比如你将一个String赋值给int变量的时候,编译器就会报错,而不是等程序跑起来再报错。

Java的数组设计坏在哪儿

为了表述简单,我们假设Java支持了范型数组哈,比如<?>[]这样的表示法。

王垠原版的代码
1
2
3
4
5
6
public static void f() {
String[] a = new String[2]; // 1
Object[] b = a; // 2
a[0] = "hi"; // 3
b[1] = Integer.valueOf(42); // 4
}

上面这段代码,在第二步的时候,其实就出现了一丝不对劲。将一个String[]转化成一个Object[],这导致了数组的类型细节“逃逸”除了类型系统。

或者用更加明白的话来说:在第四步,给一个String[]里面塞一个Integer对象的时候,编译器就应该报错。

如果能够重来,应该怎么设计

如果按照完美的类型系统来设计,王垠的代码应该是这个样子的:

用范型数组的方式来重写王垠的示例
1
2
3
4
5
6
7
8
9
10
11
// 我们依然假设Java支持了范型数组
public static void f() {
// 1. a是一个数组,里面存储的是String或者子类
<String>[]a = new String[2];
// 2. b是一个数组,里面存储的类型是String的一个父类,比如是Object吧
<? super String>[]b = a;
// 3. 往a里面写String
a[0] = "hi";
// 4. 往b里面写一个Integer
b[1] = Integer.valueOf(42);
}

这个程序这回看起来正常了很多,而且根据Java范型的规则,在第二步也能顺利触发编译失败。<String>[]转换成<? super String>[],这当然不能成功,要不然后面把Integer对象往里塞的时候,类型系统就没法判断了。

问题还没有结束

<String>[]转换成<? super String>[],本质是为了读取:可以把String当作Object来读取。

而上面的例子,实现的是写入。难道范型数组,就没法支持读取了吗?

当然不。范型的上下界就是用来做这些限定的,示例代码如下:

用范型数组的方式来重写王垠的示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void f() {
// a为一个数组,里面存储的是String或者子类
<String>[] a = new String[100];

// 写入
// b中存储的是String的一个父类(也有可能是String,下同),String是下界
<? super String>[] writeonlyA = a;
// 这时候就可以写入元素了(符合下界限定)
writeonlyA[0] = "Hi";
// 无法读取元素(无法符合上界限定)
// 编译器报错,无法推断elem的类型
// elem = writeonlyA[0];

// 读取
// d为一个数组,里面存储的类型是String的一个子类,编译器会把它当作String来处理
<? extends String>[] readonlyA = a;
// 从readonlyA读取
String elem = readonlyA[0];
// 向readonlyA写入
// 编译器报错,无论等号右面是什么类型,都无法保证符合类型约定,因为readonlyA没有明确的下界
// readonlyA[0] = "Hi";
}
  • 类型参数中通过<? super T> 限制了下界,那么写入就不会有问题,总是可以按照T类型往里面写,但读取变得不太可能。
  • 类型参数中通过<? extens T>限制了上界,那么读取就不会有问题,总是可以按照T类型读取,但写入变得不太可能。

有没有更简单的表述呢?

在类型系统中,List和array是类似的,正好Java的List支持了范型,那么我们用List重写上面的例子:

用List来重写王垠的示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void f() {
// a为一个数组,里面存储的是String或者子类
List<String> a = new ArrayList<String>();

// **类型安全的写入**
// b中存储的是String的一个父类
List<? super String> writeonlyA = a;
// 这时候就可以写入元素了(符合上界限定)
writeonlyA.add("Hi");
// 从writeonlyA里面读取的类型只能是Object
// 因为我们将a转为了更加“宽泛”的类型了
Object x = writeonlyA.get(1);
// 如果你想写入Integer(王垠的例子)
// 下面这一句会报错
// List<? super Integer> c = a;

// **类型安全的读取**
// d为一个数组,里面存储的类型是String的一个子类
List<? extends String> readonlyA = a;
// 往a里面写String
a.add("hi");
// 从readonlyA里面读取,类型系统可以很好的约束这个行为
String xx = readonlyA.get(0);
// 尝试写入的话,没有明确下界,无法写入,编译器会报错
// readonlyA.add("d");
}

可以看到,上面用List的程序,用类型系统+范型的上下界,很完美的限制了类型不安全的操作。

但是,由于数组array不支持范型,导致JVM在实现的时候,只能将数组处理成协变的,允许了类型不安全的转换操作,导致了Java类型系统的“漏洞”。


本文整理自我的知乎回答

在Java的类型系统中,数组有什么缺陷吗?

https://robberphex.com/array-on-java-type-system/

作者

Robert Lu

发布于

2021-12-06

许可协议

评论