詳解 Java 泛型

前言

從 JDK 1.5 之後,Java 引入了泛型的支持(JDK 1.5 真是一個重要的版本,枚舉、泛型、註解都是在這個版本開始支持的)。到現在,泛型已經成爲 Java 裏面最常使用的特性之一了。不管是各種 Java 集合類,還是一些開源庫裏面都能見到泛型的身影,如果我們使用得當,泛型可以大幅簡化我們的代碼。既然泛型這麼常用,那麼我們一起來看看泛型這個神奇的特性。

T 參數的由來

在沒有其他語義的情況下,我們聲明一個類是支持泛型的一般會採用 T 作爲泛型名:

/**
 * 一個使用泛型的簡單例子
 */
public class Template<T> {
    private T obj;

    public T getObj() {
        return obj;
    }

    public void setObj(T obj) {
        this.obj = obj;
    }
}

T 可以理解成 Type Variable,意爲:可變類型。上面的類只是簡單使用了泛型的語法,沒有其他意義。我們來看看這個類字節碼層面是怎樣的,找到類編譯後的輸出目錄,鍵入命令:javap -v Template.class
在這裏插入圖片描述

我們紅框順序來看一下字節碼層面是怎麼處理泛型的:

泛型擦除

紅框 1 中,class 中的常量池中有一個 Methodref 類型的常量描述了我們在 Template 定義的 obj 字段的信息,其中,該字段類型爲 java/lang/Object,即爲 Object 類,也就是說 我們設置的泛型其實並沒有實際應用到具體的類字段中去! 這其實就是我們常聽到的泛型擦除:泛型類在還沒被使用到時中並不知道字段的實際的類型是什麼,於是用了一個所有類的基類:java.lang.Object 類型的引用來承接實際值。

有一個著名的面試題:假設有定義以下類,問能不能成功編譯和運行

public class Test {
    public void test(List<String> listStr) {
    }
    
    public void test(List<Integer> listInteger) {
    }
}

答案肯定是不能的,因爲有泛型擦除的機制,在編譯器看來,上面的代碼和下面的代碼是一樣的:

public class Test {
    public void test(List listStr) {
    }
    
    public void test(List listInteger) {
    }
}

兩個方法的參數類型和個數都一樣,肯定構不成方法重載的條件。

既然泛型類內部是通過 Object 類型引用來承接的,那麼我們可不可以在指定類型爲 String 的泛型類中存入 Integer 類型的對象呢?

雖然我們不建議這麼做,並且如果通過直接的方法調用會出現語法錯誤,但是原則上來說是可行,我們需要藉助反射即可完成:

public class Main {

    public static void main(String[] args) {
        // 指定泛型具體類型爲 String
        Template<String> template = new Template<>();
        Object obj = new Integer(1);
        System.out.println(obj);
        try {
            // 通過反射將 Integer 類型的對象賦值給 template 中的 obj 字段
            Method method = template.getClass().getDeclaredMethod("setObj", Object.class);
            method.setAccessible(true);
            method.invoke(template, obj);

            // 通過反射將 template 中的 obj 字段取出
            Method getMethod = template.getClass().getDeclaredMethod("getObj");
            Object getObj = getMethod.invoke(template);
            System.out.println(getObj);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

結果:
在這裏插入圖片描述

泛型名稱

第二個紅框中,標明瞭這個泛型類的泛型參數名稱,這裏是 TT,爲什麼會有兩個 T 呢?因爲第一個 T 是固定存在的, 實際的泛型參數名稱就是 T,我們可以把代碼中的 T 改成 X 試試:

/**
 * 一個使用泛型的簡單例子
 */
public class Template<X> {
    private X obj;

    public X getObj() {
        return obj;
    }

    public void setObj(X obj) {
        this.obj = obj;
    }
}

此時的字節碼:
在這裏插入圖片描述

那麼既然泛型存在擦除機制,又爲什麼要記錄泛型名稱呢?

這裏的作用有很多,比如在 Class 類有一個方法:getTypeParameters ,是用來獲取當前類聲明的所有泛型參數信息的,如果泛型參數不保存在編譯後的 .class 文件中,那麼這些需要使用類定義的泛型參數名的地方就拿不到了。

其二,在聲明和使用泛型的類和方法中,在 class 文件層面,需要有一個額外的表結構來記錄存在泛型參數時的類和方法簽名,這個表名爲 Signature,是虛擬機規範定義的 class 文件中類、方法、字段可能存在(當使用了泛型定義時)的屬性之一。對應的其實就是第一張圖中紅框三、四、五部分。這樣的話就可以避免由於泛型擦除機制導致的方法的簽名混亂的問題。

隱式強制類型轉換

我們回到最開始的那個 Template 泛型類,我們寫下如下調用代碼:

public class Main {

    public static void main(String[] args) {
        Template<String> template = new Template<>();
        template.setObj("hello world!");
        String gotStr = template.getObj();
        System.out.println(gotStr);
    }
}

這是一段非常簡單的代碼,我們肯定知道運行結果,不過我們從這個類的字節碼層面去看一下這個類會不會更有意思呢?
在這裏插入圖片描述

在字節碼層,main 方法中出現了類型強制轉換的字節碼(checkcast)!這個結果小夥伴們仔細思考一下不難理解:Template 類中 obj 字段爲 Object,我們使用 String 類型的引用來承接 template.getObj() 方法的返回結果對象。那麼肯定有一個地方做了強制類型轉換,我們又沒有在代碼裏面主動添加強制 Object -> String 轉換的代碼。那麼就只能是編譯器幫我們做了。我們稱這種現象爲 隱式強制類型轉換。

這種機制本身是可以讓我們少些一點代碼的。但是如果涉及到的方法是靜態方法時,我們就需要注意返回的實際類型了:


public class StaticTypeMethod {

    /**
     * 這裏在方法返回值前使用 <T> 來爲這個靜態方法聲明泛型參數
     * 因爲靜態方法沒有類對象的上下文,
     * 因此在類層面聲明的泛型參數對其是不可見的
     */
    public static <T> T getObj() {
        // 注意,這裏返回的是 Object 類型對象
        return (T) new Object();
    }

    public static void main(String[] args) {
        String s = getObj();
    }
}

這段代碼在編譯時語法是沒問題的,但是我們運行一下:
在這裏插入圖片描述

拋出類型轉換異常了。當然在這裏很容易看出問題出在哪。意料之內,也是情理之中。也正是因爲在這種情況下編譯器本身不會給出語法錯誤的提示,甚至不會有警告信息,在複雜工程中,這也是一不留神會出錯的。

?、super 和 extend

我們或許看到過以下代碼:

List list = new ArrayList<>();

List<?> list1 = new ArrayList<>();

這兩行代碼創建的 ArrayList 對象有什麼區別呢?我們先看第一句,意爲:我想創建一個默認類型(因爲我沒有顯示的指定泛型參數對應的實際類型)的列表對象,那麼此時創建的就是一個以 java.lang.Object 爲實際類型的列表。實際上,這句代碼等同於:

List<Object> list = new ArrayList<>();

再看第二行代碼:我想顯示的創建一個持有某種類型的列表,但是這個列表的實際類型我也不知道(因爲我顯示的給了一個 ? 作爲泛型實際參數)。

此時會發生什麼情況呢?我們不能再向 list1 這個容器中 add 任何元素了!爲什麼呢。因爲編譯器並不知道實際類型是什麼(因爲你給了編譯器一個問號),所以你 add 任何類型的對象都會報類型不兼容的語法錯誤。有些小夥伴可能會問:把它當成 Object 類型的容器就好了啊!抱歉,編譯器不會這麼做,除非在 “實在沒有辦法” 的情況,例:

Object obj = list1.get(0);

爲什麼把調用 list1get 方法叫 “實在沒有辦法” 的情況呢?因爲我在代碼裏面調用了 list1.get(0),這個方法是有返回值的,編譯器必須給返回值給你,否則違反了 Java 的基本語法。所以這個時候容器只能獲取第一個元素,並將其作爲 Object 類型(最保險的類型)的對象返回。

super

superextends 這兩個關鍵字在泛型中有什麼作用呢?用一句話來概述:它們可以指定泛型類型的上界和下界。舉個例子:

我們定義三個類,名爲 A B C,繼承關係爲:B 繼承於 AC 繼承於 B。現在,我們創建一個 List 容器:

List<? super A> list = new ArrayList<>();

這句定義表名:我定義了一個 List 類型的泛型對象,這個 List 對象可以接收的參數對象爲任何 AA 類型的子類對象(super 關鍵字的含義爲:以 A 作爲父類的類型)。因此我現在可以向 list 添加以下元素:

// 正常
list.add(new A());
// 正常
list.add(new B());
// 正常
list.add(new C());
// 報類型不兼容錯誤!!
list.add(new Object());

因爲我們定義 list 時指定的泛型參數類型爲:List<? super A> list,即爲 A 或者 A 的父類類型。因此,接收實際參數對象的引用類型A 或者 A 的父類類型,但是具體是哪個我們也不確定,但是由於規定了引用類型的下界爲類型 A(要麼是 A 要麼是 A 的父類,因此引用類型下界爲 A),我們有了這個信息之後,我們就可以向 list 中添加任意 A 或者 A 子類型的對象, 因爲承接這個對象的引用類型的下界爲 A (根據 Java 的多態機制,想像一下,Object 類型引用可以接收任意對象,就是因爲 Object 是所有類的父類)。因此前三句添加元素的代碼都沒有錯誤(A B C 就是類型 A 本身或者 A 類型的子類),最後一句報錯,因爲 Object 類不是 A 的子類。

此時,對於 listget 方法有什麼影響呢?因爲我們指定了 容器的引用類型A 或者 A 的父類,但是沒有指定具體的類型,因此編譯器只會返回一個最保險的類型(Object),此時 list.get 方法的返回值類型爲 Object ,想要獲取其他類型,則需要強制類型轉換。

// 無需強轉
Object obj = list.get(0);
// 需強轉
A obj = (A) list.get(0);

extends

有了 super 的基礎,我們再來看 extends 就相對簡單一點了:

List<? extends A> list = new ArrayList<>();

我們定義了一個 實際接收參數對象的引用類型爲 A 或者 A 的子類List 容器。但是具體的引用類型是什麼類型,我們也不知道,這意味着什麼呢?意味着我們不能向這個容器添加任何的元素!

// 報類型不兼容錯誤
list.add(new A());
// 報類型不兼容錯誤
list.add(new B());
// 報類型不兼容錯誤
list.add(new C());
// 報類型不兼容錯誤
list.add(new Object());

爲什麼呢?因爲前半句:List<? extends A> list 我們規定的是容器引用類型的 上界 ,爲類型 A,具體的類型是什麼呢?我們並不知道,有可能是 A 類型本身,有可能是 B,也可能是 C,既然用來接收對象的引用類型都不確定,又怎麼往裏面添加對象呢?

到這裏可能小夥伴要問了:在 super 小節,我們定義的引用類型是 ? super A,也是不確定的啊,怎麼在那裏就可以添加 A 或者 A 的子類的對象呢?請注意:我們在 super 小節定義的容器中接收對象的引用類型確實是不確定的,但是我們 定義了這個引用類型的下界,即這個用來接收對象的引用類型只能是 A 或者 A 的父類 ,那麼用這個引用類型來接收 A 類型的子類對象當然是沒有問題的(Java 多態機制)。但是卻不能接收 A 類型的父類對象(比如 Object 類型的對象)和其他非 A 子類的對象。問題點在於這裏使用 extends 時,我們規定的只是上界,不是下界,這樣的話可能的引用類型就會有無限多種(一個非 final 的類可以有無數個子類)。此時,無法確定具體的引用類型或者是確定具體的引用類型的範圍。自然無法添加元素(無法接收對象)。

不能添加元素,那這個容器還有什麼用呢?我們來看看 get 方法:剛剛提到,我們通過 extends 關鍵字規定了容器接收對象引用類型的上界。那麼反過來想,如果這個容器有元素的話,裏面的元素類型一定是 A 或者 A 的子類對象。那麼我們用 A 類型或者 A 類型的父類來接收 get 方法的返回值時就不需要強制類型轉換:

// 無需強制類型轉換
A a = list.get(0);
// 無需強制類型轉換
Object obj = list.get(0);

但是如果是 A 類型的子類型時還是需要強制類型轉換(因爲上界只規定到了 A 類型):

// 需要強制類型轉換
B b = (B) list.get(0);

這樣一看感覺 extends 在泛型中的作用有點雞肋,其實我們還可以在定義泛型類型的時候配合 extends 使用:

public class Template<X extends A> {
    private X obj;

    public X getObj() {
        return obj;
    }

    public void setObj(X obj) {
        this.obj = obj;
    }
}

我們在定義 Template 類型,指定泛型參數的時候,通過 extends 指定了這個泛型類能夠接受對象類型的上界:即 setObj 方法只能接收 A 或者 A 的子類對象作爲參數。

這一小節可能對剛接觸的小夥伴會有點不友好,其實只需要明白三點:

1、如果使用了 ? 傳遞給帶有泛型參數的類 ,那麼其內部接收對象的的引用類型就一定是不確定的。此時就需要看有沒有配合 super 或者 extends 關鍵字使用了。

2、super 關鍵字用來規定接收引用類型的下界,下界一旦確定,可以接收的對象的類型也確定了(下界類型本身或者下界類型父類的對象)。get 方法可以返回的對象引用類型只能是 Object 類型。

3、extends 關鍵字用來規定接受引用類型的上界,上界一旦確定,get 方法可以返回的對象引用類型就確定了(上界類型本身或者上街類型的父類),但是由於無法確定具體的引用類型的範圍,因此不能接收(添加: add)任何類型的對象。

好了,在這篇文章中我們探討了一下關於 Java 中泛型的一些小祕密,相信你對泛型一定有了一個不錯的理解。

如果覺得本文有什麼不正確的地方,請多多指點。

如果覺得本文對你有幫助,請不要吝嗇你的贊。

界,下界一旦確定,可以接收的對象的類型也確定了(下界類型本身或者下界類型父類的對象)。get 方法可以返回的對象引用類型只能是 Object 類型。

3、extends 關鍵字用來規定接受引用類型的上界,上界一旦確定,get 方法可以返回的對象引用類型就確定了(上界類型本身或者上街類型的父類),但是由於無法確定具體的引用類型的範圍,因此不能接收(添加: add)任何類型的對象。

好了,在這篇文章中我們探討了一下關於 Java 中泛型的一些小祕密,相信你對泛型一定有了一個不錯的理解。

如果覺得本文有什麼不正確的地方,請多多指點。

如果覺得本文對你有幫助,請不要吝嗇你的贊。

謝謝觀看。。。

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