泛型中的PECS原则以及使用注意
一、泛型中的型变(协变、逆变、不可变)
1. 什么是型变
Object a = new String("ABCDEFG");
String 作为Object的子类,可以直接将子类对象赋值给父类。这个操作既达到了型变。
但是使用泛型类型时,是无法型变的。
例子:
List<String> strs = new ArrayList<>();
// 这下面一行代码将会编译器报错,让我们避免后续的运行时异常
List<Object> objs = strs;
// 假设上一步的操作是被允许的, 紧接着将一个整数放入一个字符串列表
objs.add(1);
// 这里取出的时候将会报异常,ClassCastException 无法将整数转化为字符串
String s = strs.get(0);
Java为了保证运行时安全,所以禁止了这样的操作。
实际开发中,开发者是需要语言对泛型类型的型变的支持。所以引出了协变
、逆变
、不可变
的实现思想。
以此来支持泛型的型变
开始之前,先做两个准备工作
-
准备几个类并明确继承关系,每向右缩进一次,作为一次子类实现。
- Fruit
- Banana
- Apple
- RedApple
- GreenApple
- Fruit
-
准备两个支持泛型的类Orchard(果园模拟产出角色:输出),shop(店铺模拟处理角色:输入)
public static class Orchard<T> {
T t;
public void set(T t) {
this.t = t;
}
public T get() {
return t;
}
}
public static class Shop<T> {
T t;
public void set(T t) {
this.t = t;
}
public T get() {
return t;
}
}
2. 什么是协变(Covariance)
概念:一个类型规则或者类型构造器保持了子继承父的类型关系,即:子类型 <= 父类型
作用:能够接收比原始指定的派生类型的派生程度更具体的类型
实际开发中,需要子泛型类型能够型变为父泛型类型。
直觉上的实现代码:
Orchard<Fruit> orchardF = new Orchard<Apple>();
这当然是无法通过编译的,需要扩大左边类型的赋值范围,需要通配符<? extend X>
来接收更具体的类型范围
Orchard<? extends Fruit> orchardF = new Orchard<Apple>();
扩大了接收类型为Fruit及其子类。依然维持读取类型为Fruit,不管协变类型是哪个Fruit子类,读取Fruit都是类型安全的。
Orchard<? extends Fruit> orchardF = new Orchard<Apple>();
Fruit fruit = orchardF.get();
不可以进行写入,以下代码将报错:
Orchard<? extends Fruit> orchardF = new Orchard<Apple>();
orchardF.set(new Apple());
在使用<? extends E>
的泛型集合中,对于元素的类型,编译器只知道元素是继承自E
,具体是E
的那个子类,这是无法知道的,所以向一个无法知道具体类型的泛型集合中插入元素是不能通过编译的。但是,由于知道元素是继承自E
,所以从这个泛型集合中取Fruit
类型的元素是可以的。
Java中的协变:在Java中通常将通配符? extends E
限制上边界来完成协变(限定输出类型的上边界)。保证了接收类型协变,读取类型安全。
值得注意的是,类型变量的上限可以为多个,必须使用&
符号相连接,例如 List<T extends Number & Serializable>
;其中,&
后必须为接口;
3. 什么是逆变(Contravariance)
概念:一个类型规则或者类型构造器逆转了子继承父的类型关系。即:以父类型接受最后输出子类型。
作用:能够接收比原始指定的派生类型的派生程度更小(不太具体)的类型。
在进行消费操作时如果需要使用父类型的操作,这需要下边的实现
Shop<Apple> shopA = new Shop<Fruit>();
这也是不被允许的,需要使用<? super X>
通配符来扩大接收类型范围,来接收实际操作对象是泛型为父类型Fruit
的具体对象。
Shop<? super Apple> shopA = new Shop<Fruit>();
这样似乎看起来有些问题,扩大了类型接收范围,也扩大输入范围,因为声明类型默认是以实际泛型类型作为上边界的,像下面这样:
Shop<Fruit> shopF = new Shop<Fruit>();
shopF.set(new Apple());
shopF.set(new Banana());
使用了父类型,同时又被迫扩大了输入范围(期望只输入Appel及其子类),这显然不正确。
这便是**<? super E>
通配符的作用所在,扩大赋值类型范围,限制输入类型范围**,称之为限制下边界。
输入例子:
Shop<? super Apple> shopA = new Shop<Fruit>();
shopA.set(new Apple());
shopA.set(new RedApple());
shopA.set(new Banana());//编译错误,Banner不是Apple的子类
关于下边界的理解,在上面的例子中:<? super Apple>
将Apple指定为对象Shop<Fruit>()
的输入类型的下边界。
在使用<? super E>
的泛型集合中,元素类型是E
的父类,但无法知道是那个具体的父类,因此读取时无法确定以某个具体的父类进行读取(只能以Object读取),可以插入Appel与Appel的子类,因为这个集合中的元素都是Apple的父类。(意思就是插入Appel与他的子类后,可以安全的转为Appel父类的引用)
Java中的逆变:在Java中通常将通配符<? super E>
限制下边界,允许声明的泛型为E
的父类对象,完成逆变,限定了输入类型的下边界,从输入处隔绝了类型不安全。
4. 不可变性(Invariance)
概念:不满足协变的同时不满足逆变即为不可变。
不使用通配符的情况下都是不可变的。
Shop<Fruit> shopF = new Shop<Fruit>()
5. 总结
泛型集合的使用遵从PECS原则
PECS 代表生产者——Extends、消费者——Super (Producer----Extends, Consumer ---- Super)
只读不可写时,使用List<? extends Fruit>:Producer
只写不可读时,使用List<? super Apple>:Consumer
二、结合泛型的型变理解List、List<?>、List< Object>
1. 关于泛型使用的定义
定义:声明中具有一个或者多个类型参数(type parameter)的类或者接口,就是泛型类或者接口。泛型类和接口统称为泛型(generic type)。
每种泛型定义一组类型参数(formal type parameters),这些类型形参也被简称为类型参数(type parameter),例如对于泛型(generic type)List而言,List就是一个参数化的类型,String就是对应于类型参数的类型实参(actual type parameter)。
每个泛型定义一个原生类型(raw type),即不带任何类型参数的类型名称,例如,与List对应的原生类型是List。原生类型就像从类型声明中删除了所有泛型信息一样。实际上原生类型List与Java平台在所有泛型之前的接口类型List完全一样。
2. 容器类使用泛型的好处
- 安全性:在对参数化类型的容器中放入了错误即不匹配的类型的时候,编译器将会强制性进行错误提示。
- 便利性:当从容器中取出元素的时候不用自己手动将Object转换为元素的实际类型了,编译器将隐式地进行自动转换。
- 表述性:带有类型实参的泛型即参数化类型,可以让人看到实参就知道里面的元素E都是什么类型。
3. 不应该使用原生类型的原因如下
虽然使用原生类型是合法的,但不提倡这样做,因为如果使用原生类型,就丢失了泛型在安全性和表述性方面的优势;
安全性:比如可能不小心把一个java.util.Date
实例错误地放进一个原本包含java.sql.Date
实例的集合当中,虽然在编译期不会出现任何错误,但在运行期一旦尝试类型转换就会发生ClassCastException,而泛型原本就是为了避免这种问题而出现的;
表述性:不像带有类型实参的泛型即参数化类型那样,让人看到实参就知道里面的元素E都是什么类型。
泛型的子类型化的原则:List<String>
类型的原生类型是List
的一个子类型,而不是参数化类型List<Object>
的子类型。(泛型不可变性)
4. List、List<?>
、List<Object>
的区别
List
,即原始类型,其引用变量可以接受任何对应List<E>
的参数化类型,包括List<?>
,并且可以添加任意类型的元素。但其缺点在于不安全性、不便利性、不表述性(不应该使用原生类型的原因)。List<?>
,即通配符类型,其引用变量,同样可以接受任何对应List<E>
的参数化类型,包括List,但不能添加任何元素,但可以remove
和clear
,并非immutable(不可变)
集合。List<?>
一般作为参数来接收外部集合,或者返回一个具体元素类型的集合,也称为通配符集合。保证了安全性和表述性。但不具有表述性,从中取出的元素是Object类型,需要通过手动转换才能得到原本的类型。List<Object>
,即实际类型参数为Object的参数化类型,其引用变量可以接受List,可以添加元素,但不能接受除了其本身外的任何参数化类型(泛型不可变性)。
可以看到相比参数化类型的List<Object>
,List<?>
缺点在于不能添加任何元素并且不具有便利性,如果这无法满足功能要求可以考虑使用泛型方法和有边界的通配符。
三、开发中遇到的问题
Object参数与List<Object>
参数的方法调用哪一个?
public boolean lSet(List<Object> value) {
...
}
public boolean lSet(Object value) {
...
}
调用时
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
lSet(list); //调用了lSet(Object value)
通过上面的原理,可以知道这种情况是由于泛型的不可变性导致的。要想要lSet(list)
调用lSet(List)
类型的方法,有以下几种方案。
- 将传入的
List<Integer>
转换为List
,这样List<Object>
可以接受; - 修改
lSet(List)
定义为以下几种之一即可
// 参数传入之后,在方法内部不能对value进行添加元素的操作
public boolean lSet(List<?> value) {
...
}
// 或者List<? extends Object>,在方法内部都不能对value进行添加元素的操作,这种方案与上面的方案都属于采用【协变】的方式。
public boolean lSet(List<? extends Integer> value) {
...
}
// 采用【逆变】的方式声明,在方法内部能够对value进行写操作,但是进行读操作时,只能以根类Object进行读取,读取之后可以进行强转操作。
public boolean lSet(List<? super Integr> value) {
...
}
// 采用原型方式,不建议使用集合原型List等作为参数类型
V… values与Collection<V> values
的区别
Java可变参数
定义方法时,在最后一个形参后加上三点...
,就表示该形参可以接受多个参数值,多个参数值被当成数组传入。上述定义有几个要点需要注意:
- 可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数
- 由于可变参数必须是最后一个参数,所以一个函数最多只能有一个可变参数
- Java的可变参数,会被编译器转型为一个数组
- 变长参数在编译为字节码后,在方法签名中就是以数组形态出现的。这两个方法的签名是一致的,不能作为方法的重载。如果同时出现,则不能通过编译。可变参数可以兼容数组,反之不成立。
可能出现的问题
使用Object...
作为变成参数:
public void foo(Object... args) {
System.out.println(args.length);
}
foo(new String[]{"arg1", "arg2", "arg3"}); //3
foo(100, new String[]{"arg1", "arg1"}); //2
foo(new Integer[]{1, 2, 3}); //3
foo(100, new Integer[]{1, 2, 3}); //2
foo(1, 2, 3); //3
foo(new int[]{1, 2, 3}); //1
int[]
无法转型为Object[]
,因而被当作一个单纯的数组对象;Integer[]
可以转型为Object[]
,可以作为一个对象数组。
V… values的含义
通过变长参数的定义,V... values
作为函数形参时,将会根据被虚拟机解析为V[] values
。其中的泛型类型将会替换为实际的泛型参数类型。
Collection<V> values
的含义
它作为函数形参时,将会匹配实现了该接口的类型,比如ArrayList<V>
。