Java中的泛型
一、泛型的概念
泛型是 Java SE5 出现的新特性,泛型的本质是类型参数化或参数化类型,在不创建新的类型的情况下,通过泛型指定的不同类型来控制形参具体限制的类型。
二、泛型的意义
一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。
Java 在引入泛型之前,表示可变对象,通常使用 Object 来实现,但是在进行类型强制转换时存在安全风险。有了泛型后:
编译期间确定类型,保证类型安全,放的是什么,取的也是什么,不用担心抛出 ClassCastException 异常。
提升可读性,从编码阶段就显式地知道泛型集合、泛型方法等处理的对象类型是什么。
泛型合并了同类型的处理代码提高代码的重用率,增加程序的通用灵活性。
Java中 ? extends T 和 ? super T 的理解
Producer Extends Consumer Super
上界<? extends T>
不能往里存,只能往外取
extend 为上界, super为下界
1 |
|
list.add(new Son());这行会报错:The method put(Son) is undefined for the type List<capture#1-of ? extends Father>
1 |
|
即使你指明了为Son类型,也不能用add方法添加一个Son对象。
list中为什么不能加入Father类和Father类的子类呢,我们来分析下。
List<? extends Father>
表示上限是Father,下面这样的赋值都是合法的
1 |
|
如果List<? extends Father>
支持add方法的话:
- list1可以add Father和所有Father的子类;
- list2可以add Son和所有Son的子类;
- list3可以add LeiFeng和所有LeiFeng的子类。
下面代码是编译不通过的:
1 |
|
原因是编译器只知道容器内是Father或者它的派生类,但具体是什么类型不知道。可能是Father?可能是Son?也可能是LeiFeng,XiaoMing?编译器在看到后面用Father赋值以后,集合里并没有限定参数类型是“Father“。而是标上一个占位符:CAP#1,来表示捕获一个Father或Father的子类,具体是什么类不知道,代号CAP#1。然后无论是想往里插入Son或者LeiFeng或者Father编译器都不知道能不能和这个CAP#1匹配,所以就都不允许。
所以通配符<?>
和类型参数的区别就在于,对编译器来说所有的T都代表同一种类型。比如下面这个泛型方法里,三个T都指代同一个类型,要么都是String,要么都是Integer。
1 |
|
但通配符<?>
没有这种约束,List<?>
单纯的就表示:集合里放了一个东西,是什么我不知道。
所以这里的错误就在这里,List<? extends Father>
里什么都放不进去。
List<? extends Father> list
不能进行add,但是,这种形式还是很有用的,虽然不能使用add方法,但是可以在初始化的时候一个Season指定不同的类型。比如:
1 |
|
另外,由于我们已经保证了List中保存的是Father类或者他的某一个子类,所以,可以用get方法直接获得值:
1 |
|
下界<? super T>
不影响往里存,但往外取只能放在Object对象里
下界用super进行声明,表示参数化的类型可能是所指定的类型,或者是此类型的父类型,直至Object。
1 |
|
因为下界规定了元素的最小粒度的下限,实际上是放松了容器元素的类型控制。既然元素是Father的基类,那往里存粒度比Father小的都可以。出于对类型安全的考虑,我们可以加入Father对象或者其任何子类(如Son)对象,但由于编译器并不知道List的内容究竟是Father的哪个超类,因此不允许加入特定的任何超类(如Human)。而当我们读取的时候,编译器在不知道是什么类型的情况下只能返回Object对象,因为Object是任何Java类的最终祖先类。但这样的话,元素的类型信息就全部丢失了。
PECS原则
最后看一下什么是PECS(Producer Extends Consumer Super)原则,已经很好理解了:
如果参数化类型表示一个生产者,就使用`<? extends T>;如果它表示一个消费者,就使用<? super T>
只读不可写时,使用List<? extends Fruit>
:Producer
只写不可读时,使用List<? super Apple>
:Consumer
总结
- extends 可用于返回类型限定,不能用于参数类型限定(换句话说:? extends xxx 只能用于方法返回类型限定,jdk能够确定此类的最小继承边界为xxx,只要是这个类的父类都能接收,但是传入参数无法确定具体类型,只能接受null的传入)。
- super 可用于参数类型限定,不能用于返回类型限定(换句话说:? supper xxx 只能用于方法传参,因为jdk能够确定传入为xxx的子类,返回只能用Object类接收)。
- ? 既不能用于方法参数传入,也不能用于方法返回。
带有super超类型限定的通配符可以向泛型对象中写入,带有extends子类型限定的通配符可以向泛型对象读取。
什么是泛型擦除?
其实我们很常见这个问题,你甚至经常用,只是没有去注意罢了,但是很不碰巧这样的问题就容易被面试官抓住。下面先来看一段代码吧。
1 |
|
这几段代码简单、粗暴、又带有很浓厚的熟悉感是吧。那我接下来要把一个数字1
插入到这三段不一样的代码中了。
作为读者的你可能现在已经黑人问号了????你肯定有很多疑问,这明显不一样啊,怎么可能。
1 |
|
不好意思,有图有真相,我就是插进去了,要是你还不信,我还真没办法了。
探索真相
上述的就是泛型擦除的一种表现了,但是为了更好的理解,当然要更深入了是吧。虽然List
很大,但却也不是不能看看。
两个关键点,来验证一下:
- 数据存储类型
- 数据获取
1 |
|
我想,其实你也能够懂了,这个所谓的泛型T
最后会被转化为一个Object
,最后又通过强制转化来进行一个转变。从这里我们也就能够知道为什么我们的数据从前面过来的时候,String
类型数据能够直接被Integer
进行接收了。
带来什么样的问题?
(1) 强制类型转化
这个问题的结果我们已经在上述文章中提及到了,通过反射的方式去进行插入的时候,我们的数据就会发生错误。
如果我们在一个List<Integer>
中在不知情的情况下插入了一个String
类型的数值,那这种重大错误,我们该找谁去说呢。
(2)引用传递问题
上面的问题中,我们已经说过了T
将在后期被转义成Object
,那我们对引用也进行一个转化,是否行得通呢?
1 |
|
如果你这样写,在我们的检查阶段,会报错。但是从逻辑意义上来说,其实你真的有错吗?
假设说我们的第一种方案是正确的,那么其实就是将一堆Object
数据存入,然后再由上面所说的强制转化一般,转化成String
类型,听起来完全ok,因为在List
中本来存储数据的方式就是Object
。但其实是会出现ClassCastException
的问题,因为Object
是万物的基类,但是强转是为子类向父类准备的措施。
再来假设说我们的第二种方案是正确的,这个时候,根据上方的数据String
存入,但是有什么意义存在呢?最后都还是要成Object
的,你还不如就直接是Object
。
解决方案
其实很简单,如果看过一些公开课想来就见过这样的用法。
1 |
|
相比较于之前的Part
而言,他多了<T extends Parent>
的语句,其实这就是将基类重新规划的操作,就算被编译,虚拟机也会知道将数据转化为Parent
而不是直接用Object
来直接进行替代。
应用场景
这里需要感谢给我提出问题的大佬读者:挖掘机技术
该部分的思路来自于Java泛型中extends和super的区别?
上面我们说过了解决方案,使用<T extends Parent>
。其实这只是一种方案,在不同的场景下,我们需要加入不同的使用方法。另外官方也是提倡使用这样的方法的,但是我们为了避免我们上述的错误,自然需要给出一些使用场景了。
基于的其实是两种场景,一个是扩展型super
,一个是继承型extends
。下面都用一个列表来举例子。
统一继承顺序
1 |
|
<T extends Parent>
继承型的用处是什么呢?
其实他期待的就是这整个列表的数据的基础都是来自我们的Parent
,这样获取的数据全部人的父类其实都是来自于我们的Parent
了,你可以叫这个列表为Parent
家族。所以也可以说这是一个适合频繁读取的方案。
1 |
|
<T super Parent>
扩展型的作用是什么呢?
你可以把它当成一种兼容工具,由super
修饰,说明兼容这个类,通过这样的方式比较适用于去存放上面所说的Parent
列表中的数据。这是一个适合频繁插入的方案。
1 |
|
总结
PECS原则 是 针对list本身来说的
生产者要从list中取出来用
fruit apple
extends 左边类型 大于或等于 右边类型
Plate`<? extends Fruit> p1=new Plate
extends 利于取
fruit food
super 左边的类型 小于或等于 右边类型
Plate`<? super Fruit> p1=new Plate
super利于存
泛型的协变和逆变
从面向对象说起
Java作为一门面相对象的语言,当然是支持面相对象的三大基本特性的,反手就蹦出三个词:封装、继承、多态。
我们假设有三个类,动物、猫、狗。父类是动物Animal,有两个子类猫Cat和狗Dog。
那在Java中或其它任何支持面相对象的语言中,子类可以把引用赋值给父类。下面这段代码没有任何问题:
1 |
|
理论上来说,一只猫是一只动物,一只狗也是一只动物,所以这完全是可以理解的。其实,这也是SOLID原则中的“里氏替换原则”的一种体现。
数组的协变
如果一只猫是一只动物,那一群猫是一群动物吗?一群狗是一群动物吗?Java数组认为是的。于是你可以这样写:
1 |
|
这看起来也没有什么问题。但既然都是一群动物了,我往这一群动物中添加一只猫、一只狗,它还是一群动物,这应该是合理的对吧?来看看这段代码:
1 |
|
很好,编译没有任何问题。但是一运行,会抛出一个运行时异常:ArrayStoreException
。这个异常头顶的注释已经写得很明显了,如果你往数组中添加一个类型不对的对象,就会抛这个异常。它是从JDK 1.0就存在的一个异常。
这么一想,对啊,animals虽然门面上是一个Animal数组,但是它运行时的本质还是一个Cat数组啊,一个Cat数组怎么能添加一个Dog呢?但Java编译器并没有这么智能,而且上述代码在编
译器看来也是合理合法的,所以也就让它编译过了。
所以这种情况,编译器100%过,而运行时100%抛异常,这不是大写的BUG是啥?
如果Cat是Animal的子类型,那么Cat[]
也是Animal[]
的子类型,我们称这种性质为协变(covariance)。Java中,数组是协变的。
泛型的不变性
在Java 1.5之前,是没有泛型的。那个时候从集合中存取对象都是Object类型,所以每次取出对象后必须进行强转:
1 |
|
如果不小心存入集合中对象类型是错的,会在运行时报强转异常。而1.5提供泛型以后,可以让编译器自动帮助转换,并对代码进行检查,使程序更加安全。
在Java8又加入了泛型的类型推导功能,使用泛型以后,我们的代码看起来变得简洁又安全了:
1 |
|
《Effective Java》中,第28条(第三版)说,列表优先于数组。Java在使用列表+泛型时,吸取了上面数组的教训。前面提到,Java中数组是协变的,所以会有些问题。而Java中的泛型是不变(invariance)的,也就是说,List<Cat>
并不是List<Animal>
的子类型。所以像下面这样写,编译器会直接报错。
1 |
|
这样就可以在编译期对代码进行检查,防止它在运行期才发现错误抛异常。
不变不能解决所有问题
泛型是不变的,所以我们使用泛型的时候,能够更加安全。
但是在使用一门面向对象的语言中,我们难免会有需要集合也支持一些面向对象的特性的场景。我们可以简单地把它们分成生产场景和消费场景。
消费场景的协变
比如,我希望有一个Animal的集合,我不用去管它里面存的具体类型是什么,但我每次从这个集合取出来的,一定是一个Animal或其子类。这是一种典型的消费场景,从集合中取出元素来消费。
在消费场景,Java提供了通配符和extends
关键字来支持泛型的协变。来看看这段代码:
1 |
|
也就是说,虽然因为泛型的不变性,List<Cat>
并不是List<Animal>
的子类型,但Java通过其它方式来支持了泛型的协变,List<Cat>
是List<? extends Animal>
的子类型。与此同时,Java在编译器层面通过禁止写入的方式,保证了协变下的安全性。
为什么协变下不能写入呢?因为协变下写入是不安全的,想想文章最开头那个数组的协变的例子。
生产场景的逆变
我们希望有一个集合,可以往里面写入Animal及其子类。那可以通过super
关键字来定义泛型集合:
1 |
|
逆变(contravariance),也称逆协变,从名字可以看出来,它与协变的性质是相反的。也就是说,List<Animal>
是List<? super Cat>
的子类型。
上界和下界
我们会在很多资料里看到对Java中泛型extends和super关键字的解读,说extends决定了上界,super决定了下界。
为什么这么说呢?其实看完上面两个小节,你会明白,这里的上界和下界,其实本质上指的是,在定义泛型的时候,子类型的边界。换句话说,在运行时真正的类型。
我们用X
来指代类型,看看下面两行代码:
1 |
|
任意类型通配符
在Java代码中,你可能还看到这种写法:<?>
,它代表任意类型通配符。老规矩,直接上代码:
1 |
|
也就是说,它是“无界”的,对于任意类型X
,List<X>
都是List<?>
的子类型。但List<?>
不能add,get出来也是Object类型。它同时具有协变和逆变的两种性质,上界是Object,但不能调用add方法。
那它与List<Object>
有什么区别呢?根据前面的推断,有两个比较明显的区别:
-
List<Object>
可以调用add方法,但List<?>
不能。 -
List<?>
可以协变,上界是Object,但List<Object>
不能协变。
Collection源码解读
看到这里你可能还有一些疑惑,什么时候应该用泛型的协变、逆变呢?我们来看看Collection
接口的几个方法签名(JDK 1.8版本)。
1 |
|
add和addAll
首先我们来看add和addAll方法。下面这段代码:
1 |
|
为什么这段代码可以编译通过且运行时安全?对于animals,它的泛型是<Animal>
,根据里氏替换原则,add方法可以添加Animal及其子类对象。
而对于addAll方法来说,因为方法参数声明的是<? extends E>
,而这里的E是我们声明Collection用的泛型Animal
,所以其实addAll的方法参数类型是Collection<? extends Animal>
。
结合前文我们知道,这里应用了协变的特性,Collection<Cat>
在参数传递的时候被转换成了Collection<? extends Animal>
。
而我们看源码可以发现,这里的参数传进来之后,是只读的,也就是只有消费场景,所以可以使用协变。而如果是allAll(Collection<E> c)
这种方法参数的话,就不能支持上述代码,往其中添加一个cats了。
contains和containsAll
contains方法没有使用泛型,而是直接使用了一个Object对象,它可以在任何时候调用。那为什么contains方法不像add方法一样,使用泛型,是contains(T t)
呢?
因为如果这样定义了的话,contains方法也会像add方法一样,受到协变的限制,声明为Collection<? extends Animal>
的对象就不能使用contains方法了。尽管我们确信在contains方法内部并不会修改List中的对象(因此不会有类型安全的问题)。在Java中我们没有办法解决这个问题,因此,只能写成contains(Object o)
。
对于containsAll方法,先看看这段代码:
1 |
|
为什么containsAll的方法参数是Collection<?> c
呢?
首先,不能用Collection<Object> c
,因为这样的话,就不能协变了,上述代码animals.containsAll(cats)
就会编译不通过,尽管我们知道这段代码是安全的。
然后,为什么不能像allAll方法那样,用协变Collection<? extends E> c
呢?因为我们知道,containsAll方法对Collection没有副作用,而addAll有。所以我们不能animals.addAll(objects)
,但可以animals.containsAll(objects)
。
最后,为什么又不能用逆变Collection<? super E> c
呢?因为这样的话,就不能让animals.containsAll(cats)
编译通过了。
所以只能选择Collection<?> c
。它是无界的,且具有协变性质,且取出来是Object对象,刚好内部实现也是循环去调用contains方法,与contains方法的参数类型Object一致。
同理,remove和removeAll和这两个方法是类似的写法,这里就不过多描述了。
removeIf
这个方法的参数是一个Predicate。用过Java 8的都知道,这是一个函数式接口。在这里使用了逆变,Predicate<? super E> filter
定义了filter的下界。对于Predicate来说,这里是一个生产场景,所以应该使用逆变。
这里为什么要用逆变其实也很简单,因为在调用removeIf的时候,我们只能保证animals里面的元素是Animal,但我们并不知道具体的子类型。所以下面这种代码是不安全的,
1 |
|
对我们日常工作有什么用?
看到这里,可能有的朋友已经开始吐槽了,我有必要了解这些吗?面试造火箭,工作拧螺丝?
其实不然,泛型是Java乃至很多面向对象语言的一种最基本的语言特性,所以知道它为什么这么设计是非常重要的。平时我们看源码的时候,看到这样的代码才会心中有数。
另一方面,随着编程水平的提高,难免有一些比较复杂的代码设计,或多或少会使用到泛型。合理地使用泛型、结合泛型的协变和逆变的特性能够让我们的代码变得更安全,比如上面Collection中用到的Predicate,就用了逆变的性质。
简单总结一下,Java的数组是协变的,泛型是不变的。但泛型可以通过extends关键字实现协变,通过super关键字实现逆变,分别应用于不同的场景。协变应用于消费场景,定义了上界。逆变应用于生产场景,定义了下界。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!