作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
我们花了两篇文章讲述了泛型是什么以及有什么用:
泛型只是程序员和编译器的约定。
我们可以通过泛型告诉编译器自己的意图:
呐,我现在假定这个List只能存String,你帮我盯着点,后面如果不小心放错类型,在编译期报错提醒我。
当然,要想编译器帮我们约束类型,就必须按人家的规矩办事。就好比Spring明明告诉你默认读取resources/application.yml,你非要把配置文件命名为resources/config.yml当然就报错啦。
而泛型也有一套自己的规则,我们必须遵守这些规则才能让编译器按我们的意愿做出约束。
这些规则是谁定的呢?当然是JDK的那群秃子咯。
今天我们来学习泛型通配符。
在讲述通配符的语法规则时,我会尽量给出自己的理解,让大家更容易接受它们。另外需要说明的是,在泛型相关的文章里我们总是以List元素存入、取出举例子,是因为容器类是我们接触最多的,这样更好理解。实际上对于泛型类、泛型方法都是适用的,并不一定要是容器类。
JDK1.5以后,我们全面跨入泛型时代。
假设现在有一个需求:设计一个print方法打印任意类型的List。
你想显摆一下刚学的泛型,于是这样设计:
public class GenericClassDemo {
public static void main(String[] args) {
List integerList = new ArrayList<>();
print(integerList);
}
public static void print(List list) {
// 打印...
}
}
咋一看没问题,但需求是打印任意类型的List。目前的print()只能接收List
你想了想,Object是所有对象的父类,我改成List
悲剧,这下连List
实际编码时,常见的错误写法如下:
// 错误写法1:间接传递(通常发生在方法传参,比如将stringList传给print(List
总之,list引用和实际指向的List容器类型必须一致(赋值操作左右两边的类型必须一致)。
JDK推荐的写法:
// 比较啰嗦的写法
List list = new ArrayList();
List list = new ArrayList();
// 省略写法,默认左右类型一致
List list = new ArrayList<>();
List list = new ArrayList<>();
我们在前面已经了解到,泛型底层其实还是Object/Object[],所以上面的几种写法归根到底都是Object[]赋值给Object[],理论上是没有问题的。
那么我们不禁要问:既然底层都支持了,为什么编译器要禁止这种写法呢?
我们从一正一反两个角度来思考这个问题。
首先,Object和String之间确实有继承关系,但List
其次,讨论泛型时,大家应该尽量从语法角度分析。
对于:
List
左边List
如果上面的论述还是缺乏说服力,那么我们干脆假设List
先来看看数组是怎么处理类似问题的:
数组底层和泛型不同,泛型底层都是Object/Object[],而数组是真的分别创建了Object[]和String[],而且允许String[]赋值给Object[]。但这不是它骄傲的资本,反而是它的弱点,给了异常可趁之机:
public static void main(String[] args) throws Exception {
// 直接往String[]存Integer会编译错误
String[] strings = new String[3];
strings[0] = "a";
strings[1] = "b";
strings[2] = 100; // COMPILE ERROR!
// 但数组允许String[]赋值给Object[]
Object[] objects = strings;
// 这样就能通过编译了,但运行期会抛异常:ArrayStoreException
objects[2] = 100;
}
数组允许String[]赋值给Object[],但却把错误被拖到了运行期,不容易定位。
同样的,如果泛型也允许这样的语法,那就和数组没区别了:
这么看来,泛型强制要求左右两边类型参数一致真是明智的举措,直接把错误扼杀在编译期。
在之前介绍泛型时,我们观察的维度只有存入和取出,实际上泛型还有一个很重要的约束:指向。为什么之前不提这个概念呢?因为之前接触的泛型都太简单了,比如List
另外,千万别以为List
至此,我们完善了泛型最重要的两个概念:指向、存取。
对于简单泛型而言:
后面学习通配符时,也请大家时刻保持清醒,多想想当前list可以指向什么类型的List,可以存取什么类型的元素。如果你觉得上面的推演太绕了,那么就记住:简单泛型的左右两边类型必须一致。
既然泛型强制要求左右两边类型参数必须一致,是否意味着永远无法封装一个方法打印任意类型的List?如何既能享受泛型的约束(防止出错),又能保留一定的通用性呢?
答案是:通配符。
我把List
比如,有时我们需要list能指向不同类型的List(希望print()方法能接收更多类型的List)、有时我们又希望泛型能约束元素的存入和取出。但指向和存取往往不可兼得,具体要选用哪种泛型,需要根据实际情况做决定。
通配符所谓的上边界、下边界其实是对“指向”来说的。比如
List list = new ArrayList
extends是上边界通配符,所以对于List,元素类型的天花板就是Number,右边List的元素类型只能比Number“低”。换句话说,List只能指向List
记忆方法: List list = ...,把?看做右边List的元素(暂不确定,用?代替),? extends Number表示右边元素必须是Number的子类。
你可能会问:
之前简单泛型List
其实换个角度就是,Java规定简单泛型左右类型必须一致,但有些情况又要考虑通用性,所以又搞出了extends,允许List指向子类型List。
之前我们假设过,如果允许简单泛型指向指向子类型List,那么存取会出问题:
现在extends通配符放宽了指向限制(List允许指向List
卧槽,我以为有什么高招,结果用了extends后直接不让存了。不过想想,确实是无奈之举。
public static void main(String[] args) {
List integerList = new ArrayList<>();
integerList.add(1);
List longList = new ArrayList<>();
longList.add(1L);
List numberList = new ArrayList<>();
numberList = 随机指向integerList或longList等子类型List;
numberList.add(1); // 由于无法确定numberList指向哪个List,所以干脆禁止add(万一指向integerList,那么add(1L)就不合适了,取出时可能转型错误)
}
还不是很明白?那就再举个例子:
但是对于取出,extends可不含糊:
public static void main(String[] args) {
List integerList = new ArrayList<>();
integerList.add(1);
List longList = new ArrayList<>();
longList.add(1L);
List numberList = integerList; // 不管numberList指向integerList还是longList
Number number = numberList.get(0); // 取出来的元素都可以转Number,因为Long/Integer都是它子类
}
看到这,我们应该有所体会:对于泛型而言,指向和存取是两个不同的方向,很难同时兼顾。要么指向放宽,存取收紧;要么指向收紧,存取放宽。
extends小结:
相比简单泛型,extends虽然能大大提高指向的通用性,但为了防止出错,不得不禁止存入元素,也算是一种取舍。换句话说,print(List list)对于传入的list只能做读操作,不能做写操作。
super是下边界通配符,所以对于List,元素类型的地板就是Integer,右边List的元素类型只能比Integer“高”。换句话说,List只能指向List
记忆方法: List list = ...,把?看做右边List的元素(暂不确定,用?代替),? super Integer表示右边元素必须是Integer的父类。
super的特点是:
至此,我们发现Java同时满足了:
说完指向问题,我们再来探讨一下存取问题。思路还是一样,既然Java允许List指向List
假设存在class Human implement Swimming, Speaking,那么Swimming和Speaking都是Human的父类/父接口。由于List可以指向父类型List,要么指向SwimmingList,要么指向SpeakingList。
public static void main(String[] args) {
List swimmingList = new ArrayList<>();
// 假设加入了很多实现了Swimming接口的元素,比如Dolphin(海豚)
// swimmingList.add(dolphin)...
List speakingList = new ArrayList<>();
// 假设加入了很多实现了Speaking接口的元素,比如Parrot(鹦鹉)
// speakingList.add(parrot)...
List humanList = swimmingList / speakingList; // 指向随机的List
humanList.add(...) // 是否应该允许存入 Parrot(鹦鹉)?
}
此时对于List,是否应该允许加入 Parrot(鹦鹉)呢?答案是最好不要。因为humanList的指向是不确定的,如果刚好指向的是swimmingList,那么list.add(parrot)显然是不合适的。
只有存入Human及其子类才是安全的:
介绍完super的存入,最后聊聊super的取出。由于List可以指向任意Human父类型的List,可能是SwimmingList,也可能是SpeakingList。这意味取出的元素可能是Swimming,也可能是Speaking,是不确定的,所以用Swimming或Speaking都不太合适。
那能不能强转为Human呢?答案是不行。假设humanList指向的是swimmingList,而swimmingList里存的是Shark、Dolphin、Human,此时list.get(0)得到的是 Shark implements Swimming,强转为Human显然不合适。
super小结
讲完最难的两个通配符,?就很简单了。它类似于List,允许指向任意类型的List。
再分析一下存和取:
泛型本身比较复杂,能把简单的T用熟练的已经不多,更别说用上通配符了。但从语法本身来说,通配符就是为了让赋值更具通用性。原先泛型赋值只能是同类型之间赋值,不利于抽取通用方法。而使用通配符后,就可以在一定程度上开放赋值限制。
?是开放限度最大的,可指向任意类型List,但在对List的方法调用上也是限制最大的,具体表现在:
extends和super指向性各砍了一半,分别指向子类型List和父类型List,但方法使用上又相对开放了一部分:
所以如果要用到通配符,需要结合业务考虑,如果你只是希望造一个方法,接收任意类型的List,且方法内不调用List的特定方法,那就用?。而对于extends和super的取舍,《Effective Java》提出了所谓的:PECS(Producer Extends Consumer Super)
给大家举一个JDK对通配符的使用案例:
ArrayList中定义了一个addAll(Collection c)方法,我单独把这个方法拿出来:
class ArrayList extends ... {
...
public boolean addAll(Collection c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // Increments modCount
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
}
以Person为例,假设是List
public boolean addAll(Collection c) {
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew); // Increments modCount
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
此时,addAll()只能接收Person集合或者它的Person子类的集合,比如Student extends Person:
List personList = new ArrayList<>();
List studentList = new ArrayList();
personList.addAll(studentList)
为什么会选择extends呢?还是PECS原则,因为allAll()很显然是消费者场景,我更关心对参数的具体操作,而不怎么关心返回值(就是boolean提示操作成功与否)。这也是我日常使用通配符时的一个思路,PECS确实很实用。
最后,很多人会以为?等同于T,其实两者是有区别的。我们本质还是通过给T“赋值”来确定类型,只不过此时赋值给T的不再是某个具体的类型,而是某个“匹配规则”,帮助编译器确定向上、向下可以指向的List类型范围以及存取的元素类型限定。
当你使用简单泛型时,首要考虑你想把元素规定为何种类型,顺便考虑子类型的存入是否会有影响(一般不会)。而如果要使用通配符,应该先考虑接收的范围,再考虑存取操作如何取舍(PECS原则)。
个人愚见是,通配符的出发点本来是为了解决指向问题,但开放指向后为了避免ClassCastException,不得已又对存取加了限制,实际开发时要灵活利用边界限制并结合实际需求选择合适的泛型。
提问:
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬