java泛型通配符及PECS助记符的理解

转载注明出处:https://blog.csdn.net/skysukai

1、前言

在java编程过程中,我们常常用到泛型来提高程序的灵活性。但是泛型不具备协变性,类型通配符就应运而生了。即使知道了一些基本概念,也不一定能在编写代码是准确无误地使用泛型及通配符。这篇文章我会和大家一起梳理通配符。

2、泛型

这里以泛型类来说明。泛型接口和泛型方法类似,这里不做详述。

class name<T1,T2,...,Tn>

类型参数部分被一对尖括号(<>)划分,他指定了类型参数也叫类型变量,即T1,T2,…,Tn。
Java语言中的泛型,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码。
上面这段话其实也暗含了泛型的一个重要特性,即泛型不是协变的。这个结论非常重要,下面马上会再次说明。

3、一段泛型代码

先来看一段代码:

    public static void main(String[] args) {
        // 编译报错
        // required ArrayList<Integer>, found ArrayList<Number>
        ArrayList<Integer> list1 = new ArrayList<>();
        ArrayList<Number> list2 = list1;
    }

ArrayList<Integer> 不是ArrayList<Number> 的子类型,这显然与直觉不符。这其实也很简单,因为这段代码编译、类型擦除过后后,运行时已经替换为原生类型了。上面代码的伪代码应该是这样的:

    public static void main(String[] args) {
        ArrayList list1 = new ArrayList<>();
        ArrayList list2 = list1;
    }

原生类型ArrayList里面的元素应该都是Object类型的。即使是Object类型也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。所以,为了避免转型失败,第一段代码在编译期间是编译不过的。这里也从原理上说明了为什么java中的泛型,不具有协变性。

4、协变性、逆变性、不变性

经过上面一段代码的铺垫,可以来说说协变性、逆变性、不变性。
逆变与协变用来描述类型转换(type transformation)后的继承关系,其定义:如果𝐴、𝐵表示类型,𝑓(⋅)表示类型转换,≤表示继承关系(比如,𝐴≤𝐵表示𝐴是由𝐵派生出来的子类);
𝑓(⋅)是逆变(contravariant)的,当𝐴≤𝐵时有𝑓(𝐵)≤𝑓(𝐴)成立;
𝑓(⋅)是协变(covariant)的,当𝐴≤𝐵时有𝑓(𝐴)≤𝑓(𝐵)成立;
𝑓(⋅)是不变(invariant)的,当𝐴≤𝐵时上述两个式子均不成立,即𝑓(𝐴)与𝑓(𝐵)相互之间没有继承关系。
java的泛型不仅没有协变性,且是不变的,这也是一个重要的结论。

5、另一段代码

你可能对泛型、协变性有了一些感性认识,再来一段代码:

public void showKeyValue(Generic<Number> obj){
    Log.d("泛型测试","key value is " + obj.getKey());
}

public static void main(String[] args) {
	Generic<Integer> gInteger = new Generic<Integer>(123);
	Generic<Number> gNumber = new Generic<Number>(456);

	showKeyValue(gNumber);
	// showKeyValue这个方法编译器会为我们报错:Generic<java.lang.Integer> 
	// cannot be applied to Generic<java.lang.Number>
	// showKeyValue(gInteger);
}

这段代码编译不过的原因也很简单:泛型不具有协变性,Generic<Integer>不能转变为Generic<Number>,在实际应用中我们确实可能有这样的需求,一个泛型方法需要运行多种泛型类型。泛型通配符就应运而生了。

6、泛型通配符

java的泛型不具有协变性,但在很多场景中却需要协变性来增加代码灵活度。先给出通配符定义:

ArrayList<?> list=new ArrayList<?>(); //集合元素是任意类型,也是未知的类型

尖括号<>里面的?即表示泛型通配符,与泛型相比:

ArrayList<T> list=new ArrayList<T>(); //指定集合元素只能是T类型

在泛型代码中,<?>称为通配符表示未知类型。第5节里的代码改成下面这样就能编译通过了:

public void showKeyValue(Generic<?> obj){
    Log.d("泛型测试","key value is " + obj.getKey());
}

public static void main(String[] args) {
	Generic<Integer> gInteger = new Generic<Integer>(123);
	Generic<Number> gNumber = new Generic<Number>(456);

	showKeyValue(gNumber);
}

7、通配符的使用

既然通配符的存在就是解决泛型不具有协变性而存在的,笔者就曾经写过以下这样一段代码:

public abstract class Base {……}
public class ConcreteA extends Base {……}
public class ConcreteB extends Base {……} 

public static void main(String[] args) {
	ConcreteA concreteA = new ConcreteA();
	ConcreteB concreteB = new ConcreteB();
	List<?> mList = new ArrayList<>();

	mList.add(concreteA);//编译报错
	mList.add(concreteB);//编译报错
	mList.add(null);//编译通过
}

上面这段代码的出发点非常简单,泛型不具有协变性又需要向List里添加两种不同的类型,于是想到了泛型通配符。为什么会编译报错呢?在对泛型一知半解的时候,这真是让人困惑不已。
我们来看看通配符的定义,“<?>称为通配符表示未知类型。“未知类型”非常关键,它表明编译器不知道你会向List里添加到底哪种具体类型,仅有一个例外, 就是add(null)。 null是所有引用数据类型都具有的元素。试想,如果上面那段代码能够编译通过,那在调用get方法时,运行时并不知道得到的数据到底是哪种类型,很容易引起转型失败导致程序崩溃。所以,向List<?>添加元素是行不通的。那又该如何向List<?>添加元素呢?

8、通配符的下界<? super T>

下界通配符的意思是容器中只能存放T及其T的父类类型的数据。
直接给一个结论:向容器添加元素需要下界。一段代码:

public static void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 10; i++) {
        list.add(i);
    }
}

public static void main(String[] args) {
    List<Object> list1 = new ArrayList<>();
    addNumbers(list1);
    System.out.println(list1);
    List<Number> list2 = new ArrayList<>();
    addNumbers(list2);
    System.out.println(list2);
    List<Double> list3 = new ArrayList<>();
    addNumbers(list3); // 编译报错
}

代码addNumber方法中,明确定义了只能list只能存放Integer类型及其父类。为什么调用add方法添加的时候需要父类?可以简单的这样理解:父类一般来说会比子类有更少的实现细节,更加宽泛,且子类可以隐式地向上转型为父类。所以,向容器添加元素的时候需要用下界super。那get的时候可以用下界吗?答案是不能。

public static void getTest2(List<? super Integer> list) {
	// Integer i = list.get(0); //编译报错
	Object o = list.get(1);
}

获取的时候,可能是Integer的任何父类,可能是Number也可能是Object,会导致运行时异常。所以,获取元素时,不能用下界。

9、通配符的上界<? extends T>

上界通配符的意思是容器中只能存放T及其T的子类类型的数据。
直接给一个结论:从容器获取元素需要上界。一段代码:

public static double sumOfList(List<? extends Number> list) {
    double s = 0.0;
    for (int i = 0; i < list.size(); i++) {
        s += list.get(i);
    }
    return s;
}

public static void main(String[] args) {
    List<Integer> list1 = Arrays.asList(1, 2, 3, 4);
    System.out.println(sumOfList(list1));
    List<Double> list2 = Arrays.asList(1.1, 2.2, 3.3, 4.4);
    System.out.println(sumOfList(list2));
}

代码sumOfList方法中,明确定义了只能list中的类型只能是Number类型及其子类。这里理解起来就非常简单了,获取元素时子类类型如Integer、Double可以隐式的向上转型为更宽泛的父类,所以能保证添加成功。那添加的时候能用上界吗?答案是不能:

public static void addTest2(List<? extends Number> l) {
     l.add(1); // 编译报错
     l.add(1.1); //编译报错
 }

添加的时候,可能是Number的任意子类型,可能是Integer也可能是Double,但是不知道具体是什么类型。所以,添加元素的时候不能用上界。

10、助记符PECS

PECS的提法来自于java界四大圣经之一——《Effective Java》:
producer-extends, consumer-super

参数化类型表示一个生产者T,就用<? extends T>;
参数化类型表示一个消费者T,就用<? super T>.
List里的add方法表示生产了一个参数化类型实例,所以用extends
List里的get方法表示消费了一个参数化类型实例,所以用super
当然了,如果你的代码里既有add方法,又有get方法,那参数化类型就是一个具体的类型不是未知类型,也就没有必要用通配符了。

相关参考:https://www.google.com/search?sxsrf=ALeKk00pUQPD8eu43DVjXgv3u4mPCd7kBg%3A1583829984533&source=hp&ei=4FNnXtumHtHi-gSG0IywBg&q=Generics+in+the+Java+Programming+Language&oq=Generics+in+the+Java+Programming+Language&gs_l=psy-ab.3…0i30.1288.1288…2473…1.0…0.297.297.2-1…0…2j1…gws-wiz.N626n_Sb41k&ved=0ahUKEwjblvG8wo_oAhVRsZ4KHQYoA2YQ4dUDCAU&uact=5
相关参考:http://www.angelikalanger.com/GenericsFAQ/JavaGenericsFAQ.html
相关参考:http://www.jot.fm/issues/issue_2004_12/article5/
相关参考:https://docs.oracle.com/javase/tutorial/java/generics/wildcards.html
相关参考:https://www.cnblogs.com/zhenyu-go/p/5536667.html
相关参考:https://www.cnblogs.com/en-heng/p/5041124.html
相关参考:https://juejin.im/post/5b614848e51d45355d51f792

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章