Java進階知識點2:看不懂的代碼 - 協變與逆變


一、背景

要搞懂Java中的協辦與逆變,不得不從繼承說起,如果沒有繼承,協變與逆變也天然不存在了

我們知道,在Java的世界中,存在繼承機制。比如MochaCoffee類是Coffee類的派生類,那么我們可以在任何時候使用MochaCoffee類的引用去替換Coffee類的引用(重寫函數時,形參必須與重寫函數完全一致,這是一處列外),而不會引發編譯錯誤(至於會不會引發程序功能錯誤,取決於代碼是否符合里氏替換原則)。

簡而言之,如果B類是A類的派生類,那么B類的引用可以賦值給A類的引用

賦值的方式最常見有兩種。

第一:使用等於運算符顯式賦值

Coffee coffee = new MochaCoffee();

上述代碼可以分兩階段理解,首先new MochaCoffee()返回MochaCoffee的引用,然后將此引用顯式賦值給Coffee類型的引用。

第二:函數傳參賦值

public class Main {
    public static void main(String[] args) {
        function(new MochaCoffee());
    }

    public static void function(Coffee coffee) {
    }
}

基礎知識復習完后,我們正式開始進入協變與逆變的世界,首先我們來看如下常見代碼:

Coffee a[] = new MochaCoffee[10];
List<? extends Coffee> b = new ArrayList<MochaCoffee>();
List<? super MochaCoffee> c = new ArrayList<Coffee>();

這三行代碼每一行單獨看,好像都可以勉強看得懂,但是這三行代碼似乎透露出一些讓人內心秩序隱隱不安的疑惑:

MochaCoffee[]是Coffee[]的子類? 
ArrayList<MochaCoffee>是List<? extends Coffee>的子類?
ArrayList<Coffee>是List<? super MochaCoffee>的子類?

我們只學習過Class之間有繼承關系,這些數組、容器類型之間難道也有繼承關系,這種繼承關系在JDK哪一處源碼中有定義?還有沒有其他類似的情況?

如果你也有類似的問題,說明你的知識體系中缺失了一個知識點,這就是我們今天講的Java中的協變與逆變。

 二、逆變與協變

2.1 定義

假設F(X)代表Java中的一種代碼模式,其中X為此模式中可變的部分。如果B是A的派生類,而F(B)也享受F(A)派生類的待遇,那么F模式是協變的,如果F(A)反過來享受F(B)派生類的待遇,那么F模式是逆變的。如果F(A)和F(B)之間不享受任何繼承待遇,那么F模式是不變的。(這里的繼承待遇指的是前面復習到的“如果B類是A類的派生類,那么B類的引用可以賦值給A類的引用。”)

Java中絕大部分代碼模式都是不變的(大家可以安心了)。

 2.2 Java中的協變與協變模式

Java中目前已知的支持協變與逆變的模式,我總結了三類,歡迎大家補充。

2.2.1 F(X) = 將X數組化,此時F模式是協變的

Coffee a[] = new Coffee[10];
MochaCoffee b[] = new MochaCoffee[10];
a = b; //b可以賦值給a

這可以回答之前的問題,雖然MochaCoffee[]不是Coffee[]的子類,但數組化這種代碼模式是協變的,所以MochaCoffee[]也可以直接賦值給Coffee[]。

值得注意的是,雖然數組是協變的,但是數組是會記住實際類型並在每一次往數組中添加元素時做類型檢查。比如如下代碼雖然可以利用數組的協變性通過編譯,但是運行時依然會拋出異常。

Coffee a[] = new MochaCoffee[10];
a[0] = new Coffee(); //拋出ArrayStoreException

這也是數組的協變設計被廣為詬病的原因,因為異常應該盡量在編譯時就發現,而不是推遲到運行時。不過數組支持協變后,java.util.Arrays#equals(java.lang.Object[], java.lang.Object[])這種類型的函數就不需要為每種可能的數組類型去分別實現一次了。數組的協變設計有歷史版本兼容性方面的考慮等,Java的每一個設計可能不是最優的,但確實是設計者在當時的情況下可以做出的最好選擇。

2.2.2 F(X) = 將X通過<? extends X>語法作為泛型參數,此時F模式是協變的

List<? extends Coffee> a = new ArrayList<MochaCoffee>();
List<? extends MochaCoffee> b = new ArrayList<MochaCoffee>();
a = b; //b可以賦值給a

同樣的,雖然ArrayList<MochaCoffee>不是List<? extends Coffee>的子類,但是List<? extends X>這種代碼模式是協變的,所以b可以直接賦值給a。

值得注意的是,雖然利用協變性,可以將ArrayList<MochaCoffee>賦值給List<? extends Coffee>,但是賦值后,List<? extends Coffee>中不能取出MochaCoffee,同時也只能添加null。因為List跟數組不一樣,它在運行時插入元素時,類型信息已經被擦除為Object,無法做類型檢測,只能依靠聲明在編譯時做嚴格的類型檢查,List<? extends Coffee>聲明意味着這個容器中的元素類型不確定,可能是Coffee的任何子類,所以往里面添加任何類型都是不安全的,但是可以取出Coffee類型。如下:

List<? extends Coffee> a = new ArrayList<MochaCoffee>();
//a.add(new MochaCoffee()); //不能添加MochaCoffee
//a.add(new Coffee()); //也不能添加Coffee
a.add(null); //可以添加null
Coffee coffee = a.get(0); //可以取出Coffee

2.2.3 F(X) = 將X通過<? super X>語法作為泛型參數,此時F模式是逆變的

List<? super MochaCoffee> a = new ArrayList<Coffee>();
List<? super Coffee> b = new ArrayList<Coffee>();
a = b; //b可以賦值給a

ArrayList<Coffee>不是List<? super MochaCoffee>的子類,但是List<? super X>這種代碼模式是逆變的,所以b可以直接賦值給a。

值得注意的是,雖然利用逆變性,可以將ArrayList<Coffee>賦值給List<? super MochaCoffee>,但是賦值后,List<? super MochaCoffee>中不能添加Coffee,同時也只能取出Object(除非進行強制類型轉換)。List<? super MochaCoffee>聲明意味着這個容器中的元素類型不確定,可能是MochaCoffee的任何基類,所以往里面添加MochaCoffee及其子類是安全的,但是取出的類型就只能是最頂層基類Object了。如下:

List<? super MochaCoffee> a = new ArrayList<Coffee>();
// a.add(new Coffee()); //不能添加Coffee
a.add(new MochaCoffee()); //可以添加MochaCoffee
Object object = a.get(0); //只能取出Object

 

注:沒有extend和super關鍵字加持的泛型模式都是不變的,A與B之間有繼承關系,但是List<A>和List<B>之間不享受任何繼承待遇,這就解決了上面提到數組協變導致的問題,讓類型錯誤在編譯時就可以被發現。

 2.3 PECS原則

2.2.2和2.2.3中的注意事項,也體現了著名的PECS原則:“Producer Extends,Consumer Super”。

因為使用<? extends T>后,如果泛型參數作為返回值,用T接收一定是安全的,也就是說使用這個函數的人可以知道你生產了什么東西;

而使用<? super T>后,如果泛型參數作為入參,傳遞T及其子類一定是安全的,也就是說使用這個函數的人可以知道你需要什么東西來進行消費。

比如Java8新增的函數接口java.util.function.Consumer#andThen方法就體現了Consumer Super這一原則。

三、總結

1、數組是協變的。

2、extends關鍵字加持的泛型是協變的。

3、super關鍵字加持的泛型是逆變的。

4、注意數組和泛型容器中添加和獲取元素的類型限制。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM