oynix

于无声处听惊雷,于无色处见繁花

Java和Kotlin的范型

我发现,想要解释清楚一个名词,如果只是拿着定义反复说,远不如举个合适的例子来的更快更直接。而且,举的例子越形象,理解的就越快,选的对比物越独特,记忆就越持久,这样即便是过了很久,只要是到了用的时候,便会立刻回忆起这个独特的例子,从而相关知识再次被成功加载到脑子里。

序言

范型这个东西,接触也不是一天两天了,但好像就从来没有领悟透彻,需要的时候查一查,加上高度人性化的IDE,总是能满足迎面而来的种种需求,等到事后便又云里雾里,所谓知其然,更要知其所以然,这篇就来好好说说,系统性的来捋一捋。

想了一下,还是用生活中常见的东西来举例吧:手机。新世纪初曾出现过一种系统名字为塞班的手机,Symbian,后来被诺基亚收购,全球出货量相当之高,至今记录尚未被打破,在当时有个塞班手机是个很开心的事了,在上面能装QQ,通过流量和朋友聊天,相比于发短信能省下不少钱,那个时候30M的流量可以用上一个月,每天都计算着流量玩手机。时代在发展,社会在进步,再后来,更智能的Android系统和iOS系统出现了,逐步替代了塞班机器,出了塞班机能做到的事情,它还有着更先进的功能。

接下来,就用手机(Phone, Symbian, Android)、手机生产商(Producer)和手机用户(Consumer),来说说范型是什么。

在功能上,我们可以把它们之间看成是继承的关系,Phone代表的就是能打电话的设备,Symbian代表的在能打电话的基础上,还能进行一些简单的联网操作,比如发个QQ消息,打开个网页,而Android则是在Symbian的功能之上,又增加了更多的功能,比如语音通话、视频通话、看直播,等等。

1
2
3
|--Phone (打电话)
|--Symbian (打电话,发消息)
|--Android (打电话,发消息,视频通话)

1. 引出

先从熟悉的Java说起。

我们都知道,当声明完一个类型的变量后,可以用这个类型自身来初始化,也可以用它的子类来初始化,这些都是可以编译运行的

1
2
Symbian phone = new Symbian();
Symbian phone = new Android();

这个其实很好理解,你想,我声明一个Symbian是为了干嘛,不是打电话就是发消息,那么,不管给我一个Symbian还是给我一个Android,等我拿到手后,都可以满足我的需求,所以这是允许的,这也就是Java三大特性之一,多态。

说完这个,再来看看下面这种使用集合时的情况

1
2
List<Symbian> sym = new ArrayList<Symbian>();
List<Android> and = sym;

声明一个Android类型的集合,然后用一个类型是Symbian的集合来赋值,你会发现,这么写会报错,也就是不被允许的。这是因为,Symbian虽然是Android的子类,List<Symbian>却不是List<Android>的子类,这里,就涉及到了类型擦除。

简单说说什么是类型擦除。Java虽然支持范型,但是JVM里却没有范型这个东西的,Java写的代码最终都要放到JVM里去跑,所以,其中的范型都要去掉,替换成一个确定的类型,这个操作,就叫类型擦除,这个事是在编译阶段由编译器来做的。所以,List<Symbian>不是List<Android>的子类,但是,List<Symbian>Collection<Symbian>的子类。

针对这个问题,Java提供了两个通配符,? extends和? super。

2. ? extends

? extends是Java提供的范型通配符,通过extends限制了位置类型的?,用法如下

1
2
List<? extends Symbian> phone = new ArrayList<Symbian>();
List<? extends Symbian> phone = new ArrayList<Android>();

对于声明的变量phone,?代表着类型不确定,但是通过extends可以知道,它是Symbian的子类,所以在初始化的时候,可用Symbian或者它的子类,甚至它的子类的子类,都是可以的。

通过phone的get方法获取元素时,返回的类型只能是Symbian,因为对于变量phone来说,它只知道自己存储的是Symbian或其子类,具体类型并不知道,所以为了安全,它只能返回Symbian类型。假如它返回了一个Android类型,调用者拿返回值来视频通话,但是实际存储若是Symbian类型,则没有视频通过这个功能,为避免这种情况,Java将返回类型限定为Symbian。

现在想一下通过add方法向phone添加元素的情况,当存储的真实类型是Android时,这个时候是不能向其中添加Symbian类型元素的,因为Symbian是Android的子类,父类型接收子类型的实例这是可以的,因为父类型有的功能子类型都有,所以调用父类型任何方法都不会出错,但是子类型接收父类型的实例是不可以的,声明一个Android类型,用Symbian初始化,等到调用视频通过时,Symbian就傻眼了,它不会呀。为了避免这个可能出现意外的情况,所以Java直接禁止了向其中添加元素,既然解决不了问题,那就解决提出问题的人。

所以,? extends T通配符可以用自身及其子类,子类的子类初始化,从其中get获取元素时,均为T类型,且不可向其add元素。

3. ? super

看过上面的extends,这个? super T就很好理解了,?同样代表着类型不确定,但是是T的父类型,

1
2
List<? super Symbian> phone = new ArrayList<Symbian>();
List<? super Symbian> phone = new ArrayList<Phone>();

通过super通配符限定变量phone的范型,它可以使用Android,或是Symbian初始化。要知道,Java中Object是所有类的父类型,所以在此使用Object初始化,也是可以的。

通过get从中获取元素时,因为phone不知道具体类型,所以为了万无一失,它只能返回Object类型,只有Object类型才能应对所有类型的情况。通过add添加时,虽然phone不确定自己存储的是哪种类型,但是,接收Symbian及其子类型类型就一定不会出错,因为不管是Symbian的父类型还是间接父类型,只要它有的功能,Symbian就一定也有,同理,Symbian的子类型也有。

总结下来就是,? super限定符的集合,可以add添加,但是获取的都是Object类型

4. 总结

Java这些对于范型极其限定符的种种限制,其实,都是为了编译后在JVM中运行不出现问题,如果能从这一点考虑,这些就很好理解了。如果把Java中的继承关系,看成上下承接关系的话,父类在上,子类在下,子类在继承了父类型之后,又扩展了自己独特的功能,就像上面的例子中Phone、Symbian和Android的关系,这样来看,它们就像一个没有底边的三角形,坐在尖尖上的就是Object的,功能最少,越向下的子类型,功能越丰富,也就越庞大,这个关系模型需要记住,有了这个还算形象的模型,后面的一点定义会很好理解。

? extends T限定符限定了类型是T或其子类,官方说法是,限定了类型上限,结合三角形模型来看,就像是在T这一层画一条线,被这个限定符限定的,只能是这条线以下的类型,这条线即为类型上限,T为功能最小的类型,也叫协变,covariance。

? super T限定符限定了类型是T或其父类,官方说法是,限定了类型下限,结合三角形模型来看,类型T同样是一条线,可接收的类型均在线的上方,这条线是类型下限,T是功能最多的类型,这个叫逆变,contravariance,向下才能做大做强,它非要逆流而上,所以叫它逆变

当没有修饰符,只有一个T时,这个时候限定了类型只能是这种,没有其他可能,所以这个叫不变,invariance

5. 类型擦除

上面提到了类型擦除,那么如何擦除,以及擦除后是什么样子的呢?简单说,就是用实际的类型替换掉不确定的范型。举个例子

1
2
3
4
5
6
public class Test<T extends Phone> {
T phone;

public T getPhone() { return phone; }
public void setPhone(T p) { phone = p; }
}

假设这个带范型的类,擦除之后大概就是这样

1
2
3
4
5
6
public class Test {
Phone phone;

public Phone getPhone() { reurn phone; }
public void setPhone(Phone p) { phone = p; }
}

擦除之后,没有范型的类,在JVM中才可运行。这是类中范型的例子,方法参数的情况也是一样的道理。

不变的范型,替换成Object。

协变的范型,替换成上限类型。

逆变的范型,替换成Object。

6. <?>

如果仔细看上面的话,会发现一个小细节,在开始声明变量phone时,用的是? extends,到上面类型擦除的例子里,用的变成了T extends。这两个的区别就在此,

  • <?>:范型的声明
  • <T>:范型的定义
    相同点,就是二者都可以限定类型的上限,或者下限。

7. in,out

说完Java,再看看Kotlin。

Kotlin中没有extends和super,与之替换的是out和in

  • out:限定类型上限,与? extends等同,在三角模型中,上面是个死胡同,想要out就要向下后,所以限定了上限
  • in:等同? super,限定类型下限,也可以结合三角模型来记,in就是往三角里面走

8. Java的?和Kotlin的*

在Java中单独使用?当作范型类型时,它表示的所有类型,等同于:? extends Object。

Kotlin中有个与之对等的*,等同于out Any。

9. where

当有多个类型限定范型时,Java和Kotlin的写法稍有不同,Java是这样的

1
2
3
class Apple<T extends Fruits> {}

class Apple<T extends Fruits & Food> {}

Kotlin中,单个限制时使用冒号,多个时使用where

1
2
3
class Apple<T : Fruits>

class Apple<T> where T : Fruits, T : Food

10. refied

上面说过范型擦除,所以在运行时候需要知道范型确切类型信息的操作都没法用了,因为不是上限类型就是下限类型,或者是Object

1
2
3
4
5
6
7
Class Test<T> {
void print(Object item) {
if (item instanceof T) {
// 这里是获取不到的,编译也过不去
}
}
}

Kotlin也是一样的

1
2
3
4
5
6
7
class Test<T> {
fun print(item: Any) {
if (item is T) {
// 同Java一样
}
}
}

在Java中的解决方式是,将T换成Class<T>,然后通过Class.isInstance方法

1
2
3
4
5
6
7
8
9
10
Class Test<T> {
Class<T> ct;
Test(Class<T> c) {
ct = t;
}
void print(Object item) {
if (ct.isInstance(item)) {
}
}
}

在Kotlin中,也可以使用这中方法

1
2
3
4
5
6
class Test<T>(val c: Class<T>) {
fun print(item: Any) {
if (c.isInstance(item)) {
}
}
}

除此之外,Kotlin还提供了一个关键字reified,使得一种更为简答的方式可用

1
2
3
4
5
6
class Test<T> {
inline fun <T reified> print(item: Any) {
if (item is T) {
}
}
}

11. 应用 PECS

上面提到过,extends限定了类型上限的List,不能添加,只能向外提供,我们给它起个名字,就叫做生产者,Producer。同样,super限定了类型下限的List,get出来的都是Object,并没有什么实际意义,而可通过add向内添加元素,我们也给它起个名字,叫做消费者,Consumer,结合二者,便是常说的PECS,在Kotlin中,应该就是POCI了吧,但是好像还没听过有谁这么叫,在Java面前,可能Kotlin还是年轻些。

生产者和消费者模式,就是范型限定的常见应用,举个例子,说明一下。还是用手机的例子。

生产者接口,可以提供商品

1
2
3
interface Producer<out T> {
fun produce(): T
}

消费者接口,可以使用商品

1
interface Consumer<in T> {}

手机生产者

1
2
3
class PhoneProducer : Producer<Phone> {}
class SymbianProducer : Producer<Symbian> {}
class AndroidProducer : Producer<Android> {}

实例化出来生产者

1
2
3
val p1: Producer<Phone> = PhoneProducer()
val p2: Producer<Phone> = SymbianProducer()
val p3: Producer<Phone> = AndroidProducer()

分析一下,我们声明了一个类型是Producer<Phone>的变量p,Producer使用了out范型修饰符,也就是说,p的类型上限就是Phone,那么自然可以传递Phone或其子类行给p,如果反过来则不行,通俗的理解就是,我一个准备生产只带有打电话功能的手机,你给我一个Phone工厂可以生产,Symbian工厂也能生产,Android工厂更能生产了,相反,我想要一个生产Android的工厂,你给一个生产Symbian的,肯定就是不行了,这里的重点是能否生产出。

1
2
3
val p1: Producer<Android> = PhoneProducer() // 报错
val p2: Producer<Android> = SymbianProducer() // 报错
val p3: Producer<Android> = AndroidProducer()

这便是体现了限定上限范型的协变性。

生产者看完,再来看看消费者

1
2
3
class PhoneConsumer : Consumer<Phone> {}
class SymbianConsumer : Consumer<Symbian> {}
class AndroidConsumer : Consumer<Android> {}

实例化出消费者

1
2
3
val c1: Consumer<Android> = PhoneConsumer()
val c2: Consumer<Android> = SymbianConsumer()
val c2: Consumer<Android> = AndroidConsumer()

再分析一个消费者,限定符in限定了类型下限是Android,所以可以传递父类型给c,这里通俗的理解就是,我一个能把Android玩明白的消费者,你让我去玩一个只能打电话的Phone,我肯定没有问题,同理,反过来也是不行的,我只能会用只能打电话的老年机Phone,你给我一个Android,我不会用的呀,这里的重点是能否消费掉,这里体现的是范型的逆变性。

1
2
3
val c1: Consumer<Phone> = PhoneConsumer()
val c2: Consumer<Phone> = SymbianConsumer() // 报错
val c3: Consumer<Phone> = AndroidConsumer() // 报错

此外,除了生产者和消费者,还有个聚合体,叫生产消费者,ProducerConsumer,根据传入的类型T,生产时成为类型上限,消费时成为类型下限,最终,就只能是类型T,所以生产消费这体现的是不变性。

9.总结

发现每个小节的开头语,多数都是上面提到过、上面说过,下面的内容在不断的延伸上面的内容,就像台阶一样,只有把下面的看明白,才能看懂上面的。完整的看一遍下来,发现范型里也没什么深奥难懂的点,只是自己知识的盲点给它增加了几分神秘色彩,稍稍花上点时间,就可以揭开这神秘的面纱。

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2022/04/a59fca43ac2f/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道