【Java】java 中的泛型通配符——从“偷偷地”地改变集合元素说起

时间:2024-06-30 11:33:56

一直没注意这方面的内容,想来这也算是基础了,就写了这个笔记。

首先java的通配符共有三种————先别紧张,现在只是粗略的过一下,看不看其实无所谓

类型 介绍
<?> 无限定通配符,等价于 <? extends Object>
<? extends Number> 上限通配符,表示参数类型只能是 Number 或是 Number 的子类。
<? super Number> 下限通配符,表示参数类型只能是 Number 或是 Number 的父类。

然后再让我们定义四个类,下面会用到

class A {
public String getName() {
return "A";
}
} class B extends A{
@Override
public String getName() {
return "B";
}
} class BAge extends B{
@Override
public String getName() {
return "C";
} public int getAge() {
return 100;
}
} class BSize extends B{
@Override
public String getName() {
return "D";
} public int getSize() {
return -1;
}
}

从一个奇怪的现象说起

  1. 首先,我们再引入一个类 PrintAges ,用于打印 BAge 的 getAge()
class PrintAges{
public static void print(BAge[] ages){
if (ages == null)
return; for (BAge bage : ages){
if (bage != null)
System.out.println(bage.getAge());
}
}
}

仔细看看上面这个类,你觉得我写的 PrintAges 怎样?够完美吗,不会引发异常吧?我觉得也很完美了,肯定不会有异常出现在我的代码里了。

  1. 我们测试下
BAge[] temps = new BAge[]{new BAge(), new BAge()};
PrintAges.print(temps);

输出:

100
100

完美运行。

  1. 我们再增加两行
BAge[] temps = new BAge[]{new BAge(), new BAge()};
B[] barray = temps; // 新增加的第一行
barray[0] = new BSize(); // 新增加的第二行
PrintAges.print(temps);

你猜怎么着?我偷偷地改变了数组中的元素!我在 BAge 类型的数组中的元素赋了一个 BSize 的对象!

而且,编译通过了。但是肯定会有异常出现,你猜是在哪一行?

输出:

Exception in thread "main" java.lang.ArrayStoreException: JavaApp.BSize at JavaApp.JavaApplicationStudyGen.main(JavaApplicationStudyGen.java:33)

本来我以为会在 PrintAges 的 print 方法中发生异常,但是实际上新增加的第二行发生了运行时错误,赋值错误。

而在C#中,这种问题出现的可能性就更小了。C#中,新增的第一行是无法通过编译的。

那么,这种问题在集合……准确地说是在泛型里会不会出现呢?

  1. 上述问题在泛型中的讨论。

我们先对 PrintAges 添加一个 print 函数的重载

class PrintAges{
public static void print(ArrayList<BAge> list) {
if (list == null)
return; for(BAge age : list) System.out.println(age.getAge());
}
public static void print(BAge[] ages){
if (ages == null)
return; for (BAge bage : ages){
if (bage != null)
System.out.println(bage.getAge());
}
}
}

然后我们对用再次运行如下代码:

ArrayList<BAge> list = new ArrayList<BAge>();
list.add(new BAge());
ArrayList<B> yourList = list; // 编译错误
yourList.set(0, new BSize()); // star 1
BAge age = list.get(0); // star 2
PrintAges.print(list);

这次,Java 处理的比较严格,在把 ArrayList<BAge> 赋值给 ArrayList<B> 类型的对象时产生了编译错误。

在 C# 里,也是一样的,在把 ArrayList<BAge> 赋值给 ArrayList<B> 类型的对象时会产生编译错误。

一开始,我不理解这样做对 list 引用的对象 ArrayList 会产生什么负面影响。

但是,不能赋值的原因,把一个 BSize 类型的对象放在了一个实际上是 ArrayList 的集合里。而ArrayList 又假设集合中的元素类型都是 BAge 。倒不是运行时的虚拟机会假设,因为泛型最后都会类型擦除(type erasure)。其实倒不是类型擦除本身引起了这个错误,而是本来就存在这样一种现象。我给出类型擦除之后的样子是为了便于理解。

经过类型擦除之后, star 2 所在行的代码就会变成

BAge age = (BSize)list.get(0); // star 2

这样就是完全不正确的了。

也就是说,我们应该禁止类似 ArrayList<B> yourList = new ArrayList<BAge>() 这样的赋值,否则,就会出现这样的错误和意外。

说实话,B[] barray = new BAge[]{new BAge(), new BAge()} 这样的赋值操作也该被禁止的,但是 Java 就可以。看看人家 C# 就不允许这样做(笑)

记住这样的错误。接下来,我们就可以讨论 Java 的泛型通配符了。

通配符出现的原因

所以所,通配符的出现就是为了在错误避免上述错误的同时,给程序员提供一点便利

而通配符是怎么样发生作用的呢?是通过编译器给定的三条“游戏规则”(也即是上面给的表格里的规则)发生作用的。

在一开始理解的时候是需要一点逻辑能力的:

  1. 上限通配符 <? extends B> 确保了可读性, <? extends B> 表示参数类型只能是 B 或是 B 的子类 可以被编译通过的语句:
ArrayList<? extends B> list = new ArrayList<A>(); // 编译错误
ArrayList<? extends B> list = new ArrayList<B>(); // ok
ArrayList<? extends B> list = new ArrayList<BAge>(); // ok
ArrayList<? extends B> list = new ArrayList<BSize>(); // ok

基于以上的编译规则,我们可以得出以下事实:

  • 你一定能从 list 中读取到一个 B 元素,因为 list 要么指向 ArrayList<B> ,要么指向包含 B 子类对象的 ArrayList<B>
  • 你不能不能插入一个 B 元素 ,因为 list 可能指向的是 ArrayList<BSize> 或者指向 ArrayList<BAge>
  • 你不能不能插入一个 BAge 元素 ,因为 list 可能指向的是 ArrayList<BSize>
  • 你不能不能插入一个 BSize 元素 ,因为 list 可能指向的是 ArrayList<BAge>

注意,上述代码中, list 中的 T 被替换成了 ? extends B

也就是说,读取操作可以被确保,你一定能从 list 中读取到一个 B 元素 这样, list.get 方法就可以被正常使用了。

list.set(int, T) 就被替换成了 list.set(int, ? extends B),这个方法就被编译器“禁止”了。也就是说,如果你写出 list.set(0, new B())list.set(0, new BSize()) 是不行的。

在这里你肯定要提出疑问了,你不是说符合“游戏规则” <? extends B> 表示参数类型只能是 B 或是 B 的子类 就行的吗? 我只能说,文字所能传达的信息是有限的,这个表述也只适用于 ArrayList<? extends B> list = new ArrayList<A>(); 这样的赋值时刻。还是得看上述推导的“事实”

  1. 下限通配符 <? super B> 确保了写入性
ArrayList<? super B> list = new ArrayList<Object>(); // ok
ArrayList<? super B> list = new ArrayList<A>(); // ok
ArrayList<? super B> list = new ArrayList<B>(); // ok
ArrayList<? super B> list = new ArrayList<BAge>(); // 编译错误
ArrayList<? super B> list = new ArrayList<BSize>(); // 编译错误

基于以上的编译规则,我们可以得出以下事实:

  • 你一定能插入一个 B 类型的对象或者 B 子类型的对象。因为, list 要么指向包含 B 类型的 ArrayList,要么指向包含 B 超类型的 ArrayList 对象,比如: list 可能是 ArrayList<Object>ArrayList<A>
  • 你一定你不能保证读取到 B ,因为 list 可能指向 ArrayList<Object> 或者是 ArrayList<B>

这样, list.set 方法就可以被正常使用了。假设 list 指向 ArrayList<Object> ,我们把一个 B 类型的对象添加到 ArrayList<Object> 中也没错啊。

  • 或者,我们把一个 BAge 对象添加到 ArrayList<Object>ArrayList<A> 中也没错啊。
  • 或者,我们把一个 BSize 对象添加到 ArrayList<Object>ArrayList<A> 中也没错啊。

总结

  1. 通配符的出现是为了让程序员在避免上述错误的情况下能放宽一点要求,即所谓的“符合我编译器的规则,就让你舒服”
  2. ? extends B 确保了可读性,? super B 确保了写入性。
  3. ? extends B? super B 给人的感觉是逆操作。