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

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