背景
平时在看一些开源框架源码时总发现他们会或多或少的用到泛型来定义数据类型。这可以理解,毕竟牛逼的开源框架大都是为了解决一类普遍问题而存在的;但看不懂的是,有时参数或者返回值会出现诸如 extends T>
和 super T>
这样带通配符的泛型参数,这种通配符的泛型是什么意思?如果直接用指定的T
会有什么问题?这样做是为了解决什么问题?这是我的疑惑。咨询公司完全做Java开发的服务端同学后,也未能完全解惑。于是查找资料后引出今天的主题----Java泛型的协变( extends T>
)、逆变( super T>
)和不变(T
)。
举例
- RxJava框架
在定义一个Observable
后,最终会通过subscribe()
来订阅一个Observer
, 而subscribe()
参数的定义就使用了逆变(super T>
),如下所示:
/**
* 参数observer用到了逆变 super T>
*/
public final void subscribe(Observer super T> observer) {
try {
//....
subscribeActual(observer); //实际发起的订阅
//...方法
} catch (Throwable e) {
//...
}
}
map
操作符的参数Function
泛型分别使用协变和逆变实现,如下所示:
/**
* 参数mapper是一个Function接口类型,第一个参数用到了逆变 super T>,
* 第二个参数用到了协变 extends R>
*/
public final Observable map(Function super T, ? extends R> mapper) {
//...
return RxJavaPlugins.onAssembly(new ObservableMap(this, mapper));
}
- java集合框架
Collections
的工具方法copy()
分别使用协变和逆变定义了两个集合的类型,如下所示:
/**
* 目的列表使用的是逆变,源列表使用的是协变
*/
public static void copy(List super T> dest, List extends T> src) {
//...
for (int i=0; i
3.java8中Stream
的超级接口collect()
,其参数定义使用了不变和逆变,如下所示:
R collect(Supplier supplier,
BiConsumer accumulator,
BiConsumer combiner);
面对上面这些源码的定义,不禁让人产生疑惑!!!
概念
假设Orange
类是Fruit
类的子类,以集合类List
为例:
- 型变:用来描述类型转换后的继承关系(即协变、逆变和不变的统称)。比如:
List
是List
的子类型吗?答案是No,两者并没有关系,并不能相互读写数据。因此,型变是处理如List
(List extends Orange>
)和List
子类型关系的一种表达方式。 - 协变(covariance):满足条件诸如
List
是List extends Fruit>
的子类型时,称为协变。 - 逆变(covariance):满足条件
List
是List super Orange>
的子类型时,称为逆变。 - 不变(invariance):表示
List
和List
不存在型变关系。
注:子类(subclass)和子类型(subtype)不是同一个概念。
先回答文章开头提出的几个问题:
带通配符的泛型是什么意思?
----这是因为Java泛型本身不支持型变,因此引入通配符来解决泛型类型的类型转换问题,这是Java型变的通用表达式(extends T>
表示类型转换的上界;super T>
表示类型转换的下界)。如果直接用指定的T会有什么问题?
----直接使用T作为参数的类型不会有任何问题,但这会限制函数接口调用的灵活性,导致框架的通用性降低。这样做是为了解决什么问题?
----综上两点,型变处理的最终目的是在保证了运行时类型安全的基础上,并提高参数类型的灵活性。
型变
看一个不使用型变的例子:
/**
* 1.定义一个String类型的List
*/
List value1 = new ArrayList();
/**
* 2. 这里编译器报错,因为两者没有型变关系,无法直接赋值,后续操作会导致类型不安全
*/
List
上面举例说明了在不使用型变的情况下,对泛型数据的操作会面临种种困难,虽然保证了运行时参数类型的安全,却限制了接口的灵活性(编译器检查),比如:如果我们只调用value2
(List
)的get()
方法,不调用add()
方法(只读取数据不写入数据),显然此时不会有类型的安全问题,那如何限制只能调用get()
却不能add()
方法呢?当然只能靠编译器限制了,让你调add()
方法的时候编译都通不过就可以了。通配符就是干这件事的,通知编译器,限制我们对于某些方法的调用,以保证运行时的类型安全。
协变
对于上面不型变的例子,我们可以做如下调整,就可以达到协变的目的:
/**
* 2. 这里编译器不会报错
*/
List extends String> value2 = value1;
/**
* 3.但此处编译器报错了,编译器限制了写入数据的操作
*/
value2.add(1); //error
但上面的简单例子太过简单,缺少继承关系,不能明显说明问题,下面仍以Orange
类是Fruit
类的子类来举例说明:
/**
* 1.定义一个类型上界限定为Fruit的List,即协变
*/
List extends Fruit> fruits = new ArrayList<>();
/**
* 2.编译器报错,不能添加任何类型的数据
* 原因是:
* List.add(T t)函数通过上面的类型指定后,参数会变成
* extends Fruit>,从这个参数中,编译器无法知道需要哪个具体的Fruit子类型,
* Orange、Banana甚至Fruit都可以,因此,为了保证类型安全,编译器拒绝任何类型。
*/
//fruits.add(new Orange());
//fruits.add(new Fruit());
//fruits.add(new Object());
/**
* 3.此处正常!! 由于我们定义是指定了上界为Fruit,因此此处的返回值肯定至少是Fruit类型,
* 而基类型可以引用子类型
*/
Fruit f = fruits.get(0);
通过上面代码的注释可以看出,协变限制了参数中带T的方法调用,比如上面的add(T t)
方法(我们称之为消费者方法),而允许生产者方法的调用如T get(int position)
,以此来保证类型的安全。
逆变
协变的反方向是逆变,在协变中我们可以安全地从泛型类中读取(从一个方法中返回),而在逆变中我们可以安全地向泛型类中写入(传递给一个方法)。
/**
* 1.定义一个Object的List,作为原始数据列表
*/
List
通过上面代码的注释可以看出,逆变限制了读取方法的调用,比如上面的T get(int position)
方法(我们称之为生产者方法),而允许消费者方法的调用如add(T t)
,依次来保证类型的安全。
总结
extends限定了通配符类型的上界,所以我们可以安全地从其中读取;而super限定了通配符类型的下界,所以我们可以安全地向其中写入。
我们把那些只能从中读取的对象称为生产者(Producer),我们可以从生产者中安全地读取;只能写入的对象称为消费者(Consumer)。
因此这里就是著名的PECS原则:Producer-Extends, Consumer-Super。
源码分析实战
结合上文总结的PECS原则,来看文章开头提到的框架源码(这里就不贴重复的源码了),不难看出其含义了:
- RxJava中的
subscribe(Observer super T> observer)
函数由于并没有返回T类型的数据,因此是一个消费者方法,根据PECS原则,此处参数应使用逆变来提高灵活性。 - RxJava的
map
操作符函数map(Function super T, ? extends R> mapper)
,他最终的调用在MapObserver
类中的onNext()
中执行R v = mapper.apply(t)
,仍根据PECS原则,T仅做为传入参数类型,因此是个消费者参数,可以使用逆变;而R仅在返回值中出现,因此是个生产者参数,可以使用协变,来保证类型类型安全。 - java集合框架
Collections
的工具方法copy(List super T> dest, List extends T> src)
,它具体的实现是如下:
for (int i=0; i
可以看出,src
调用了get(i)
,这是一个生产者的过程,因此这里使用了协变参数;而dest
调用了set(i, t)
,这是一个消费者的过程,因此这里使用了逆变参数。
数组的协变
Java中数组是协变的:可以向子类型的数组赋予基类型的数组引用,由于数组在Java中是完全定义的,因此内建了编译期和运行时的检查,具体参见如下代码注释:
class Fruit {}
class Apple extends Fruit {}
class Jonathan extends Apple {}
class Orange extends Fruit {}
/**
* 创建了一个Apple数组,并将其赋值给一个Fruit数组引用,编译器和运行时都允许
*/
Fruit[] fruits = new Apple[10];
/**
* 将子类对象放置到父类数组中,编译器和运行时都允许
*/
fruits[0] = new Apple();
fruits[1] = new Jonathan();
try {
/**
* 将Apple的父类对象放置到子类数组中,编译器允许,但运行时检查抛出异常
*/
fruits[2] = new Fruit();
} catch (Exception e) {
Log.i(TAG, "array exception!", e);
}
try {
/**
* 将Apple的兄弟对象放置到数组中,编译器允许,但运行时检查抛出异常
*/
fruits[3] = new Orange();
} catch (Exception e) {
Log.i(TAG, "array exception!", e);
}
自限定与协变
Java中一个常见的自限定写法是:
class Base> {
T element;
T get() {
return element;
}
void set(T t) {
element = t;
}
}
这种语法定义了一个基类,这个基类能够使用子类作为其参数、返回类型、作用域。
- 协变参数类型
在非泛型代码中,参数类型不能随子类型发生变化。方法只能重载不能重写。在使用自限定类型时,方法接受子类型而不是基类型为参数:
/**
* 自限定协变参数类型
* 方法接受只能接受子类型而不是基类型为参数
* @param
*/
interface SetInterface> {
void set(T arg);
}
/**
* 具体的子类型
* 避免重写基类的方法
*/
interface SubSetInterface extends SetInterface {}
public void test5(SubSetInterface s1, SubSetInterface s2, SetInterface sb) {
/**
* 编译通过
*/
s1.set(s2);
/**
* 只能接受具体的子类型,不能接受SetInterface基类型
*/
//s1.set(sb); //error
}
- 协变返回类型
继承自限定基类的子类,将产生确切的子类型作为其返回值.不过,这种实现java的多态性已经可以达到目的(基类引用子类):
/**
* 自限定协变返回类型
* @param
*/
interface GetInterface> {
T get();
}
/**
* 具体的子类型
* 避免重写基类的方法
*/
interface SubGetInterface extends GetInterface {}
public void test4(SubGetInterface g) {
GetInterface s1 = g.get();
SubGetInterface s2 = g.get();
}
参考文档
https://www.jianshu.com/p/0c2948f7e656
https://www.cnblogs.com/en-heng/p/5041124.html
https://www.jianshu.com/p/2bf15c5265c5