Java 泛型:理解和應用

2023-05-25 06:01:27

概述

泛型是一種將型別引數化的動態機制,使用得到的話,可以從以下的方面提升的你的程式:

  1. 安全性:使用泛型可以使程式碼更加安全可靠,因為泛型提供了編譯時的型別檢查,使得編譯器能夠在編譯階段捕捉到型別錯誤。通過在編譯時檢查型別一致性,可以避免在執行時出現型別轉換錯誤和 ClassCastException 等異常。減少由於型別錯誤引發的bug。
  2. 複用和靈活性:泛型可以使用預留位置 <T> 定義抽象和通用的物件,你可以在使用的時候再來決定具體的型別是什麼,從而使得程式碼更具通用性和可重用性。
  3. 簡化程式碼,增強可讀性:可以減少型別轉換的需求,簡化程式碼,可以使程式碼更加清晰和易於理解。通過使用具有描述性的泛型型別引數,可以更準確地表達程式碼的意圖,還可以避免使用原始型別或Object型別,從而提供更多的型別資訊,使程式碼更加具有表達力

這就是泛型的概念,是 Java 後期的重大變化之一。泛型實現了引數化型別,可以適用於多種型別。泛型為 Java 的動態型別機制提供很好的補充,但是 Java 的泛型本質上是一種高階語法糖,也存在型別擦除導致的資訊丟失等多種缺點,我們可以在本篇文章中深度探討和分析。

簡單的範例

泛型在 Java 的主要作用就是建立型別通用的集合類,我們建立一個容器類,然後通過三個範例來展示泛型的使用:

  1. 沒有使用泛型的情況
  2. 使用 Object 型別作為容器物件
  3. 使用泛型作為容器物件

範例1:沒有使用泛型的情況

public class IntList {

    private int[] arr;		// 只能儲存整數型別的資料
    private int size;

    public IntList() {
        arr = new int[10];
        size = 0;
    }

    public void add(int value) {
        arr[size++] = value;
    }

    public int get(int index) {
        return arr[index];
    }

    public int size() {
        return size;
    }

    public static void main(String[] args) {
        IntList list = new IntList();

        list.add(1);
        list.add(2);
        list.add(3);

        int value = list.get(1);  // 需要顯式進行型別轉換
        System.out.println(value);  // 輸出: 2
    }
}

在上述範例中,使用了一個明確的 int 型別儲存整數的列表類 IntList,但是該類只能儲存整數型別的資料。如果想要儲存其他型別的資料,就需要編寫類似的類,導致類的複用度較低。

範例2:使用 Object 型別作為持有物件的容器

public class ObjectList {
    private Object[] arr;
    private int size;

    public ObjectList() {
        arr = new Object[10];
        size = 0;
    }

    public void add(Object value) {
        arr[size++] = value;
    }

    public Object get(int index) {
        return arr[index];
    }

    public int size() {
        return size;
    }

    public static void main(String[] args) {
        // 範例使用
        ObjectList list = new ObjectList();
        list.add(1);
        list.add("Hello");
        list.add(true);

        int intValue = (int) list.get(0);  // 需要顯式進行型別轉換
        String stringValue = (String) list.get(1);  // 需要顯式進行型別轉換
        boolean boolValue = (boolean) list.get(2);  // 需要顯式進行型別轉換
    }
}

在上述範例中,使用了一個通用的列表類 ObjectList,它使用了 Object 型別作為持有物件的容器。當從列表中取出物件時,需要顯式進行型別轉換,而且不小心型別轉換錯誤程式就會丟擲異常,這會帶來程式碼的冗餘、安全和可讀性的降低。

範例3:使用泛型實現通用列表類

public class GenericList<T> {

    private T[] arr;
    private int size;

    public GenericList() {
        arr = (T[]) new Object[10];  // 建立泛型陣列的方式
        size = 0;
    }

    public void add(T value) {
        arr[size++] = value;
    }

    public T get(int index) {
        return arr[index];
    }

    public int size() {
        return size;
    }

    public static void main(String[] args) {
        // 儲存 Integer 型別的 List
        GenericList<Integer> intList = new GenericList<>();
        intList.add(1);
        intList.add(2);
        intList.add(3);

        int value = intList.get(1);  // 不需要進行型別轉換
        System.out.println(value);  // 輸出: 2

        // 儲存 String 型別的 List
        GenericList<String> stringList = new GenericList<>();
        stringList.add("Hello");
        stringList.add("World");

        String str = stringList.get(0); // 不需要進行型別轉換
        System.out.println(str); // 輸出: Hello
    }
}

在上述範例中,使用了一個通用的列表類 GenericList,通過使用泛型型別引數 T,可以在建立物件時指定具體的型別。這樣就可以在儲存和取出資料時,不需要進行型別轉換,程式碼更加通用、簡潔和型別安全。

通過上述三個範例,可以清楚地看到泛型在提高程式碼複用度、簡化型別轉換和提供型別安全方面的作用。使用泛型可以使程式碼更具通用性和可讀性,減少型別錯誤的發生,並且提高程式碼的可維護性和可靠性。

組合型別:元組

在某些情況下需要組合多個不同型別的值的需求,而不希望為每種組合建立專門的類或資料結構。這就需要用到元組(Tuple)。

元組(Tuple)是指將一組不同型別的值組合在一起的資料結構。它可以包含多個元素,每個元素可以是不同的型別。元組提供了一種簡單的方式來表示和操作多個值,而不需要建立專門的類或資料結構。

下面是一個使用元組的簡單範例:

class Tuple<T1, T2> {
    private T1 first;
    private T2 second;

    public Tuple(T1 first, T2 second) {
        this.first = first;
        this.second = second;
    }

    public T1 getFirst() {
        return first;
    }

    public T2 getSecond() {
        return second;
    }
}

public class TupleExample {

    public static void main(String[] args) {
        Tuple<String, Integer> person = new Tuple<>("Tom", 18);
        System.out.println("Name: " + person.getFirst());
        System.out.println("Age: " + person.getSecond());

        Tuple<String, Double> product = new Tuple<>("Apple", 2.99);
        System.out.println("Product: " + product.getFirst());
        System.out.println("Price: " + product.getSecond());
    }
}

在上述範例中,定義了一個簡單的元組類 Tuple,它有兩個型別引數 T1T2,以及相應的 firstsecond 欄位。在 main 方法中,使用元組儲存了不同型別的值,並通過呼叫 getFirstgetSecond 方法獲取其中的值。

你也們可以利用繼承機制實現長度更長的元組:

public class Tuple2<T1, T2, T3> extends Tuple<T1, T2>{

    private T3 t3;

    public Tuple2(T1 first, T2 second, T3 t3) {
        super(first, second);
        this.t3 = t3;
    }
}

繼續擴充套件:

public class Tuple3<T1, T2, T3, T4> extends Tuple2<T1, T2, T3> {

    private T4 t4;

    public Tuple3(T1 first, T2 second, T3 t3) {
        super(first, second, t3);
    }
}

如上所述,元組提供了一種簡潔而靈活的方式來組合和操作多個值,適用於需要臨時儲存和傳遞多個相關值的場景。但需要注意的是,元組並不具備型別安全的特性,因為它允許不同型別的值的組合。

泛型介面

將泛型應用在介面,是在介面設計時常常需要考慮的,泛型可以提供介面的複用性和安全性。

下面是一個範例,展示泛型在介面上的使用:

// 定義一個泛型介面
interface Container<T> {
    void add(T item);
    T get(int index);
}

// 實現泛型介面
public class ListContainer<T> implements Container<T> {

    private List<T> list;

    public ListContainer() {
        this.list = new ArrayList<>();
    }

    @Override
    public void add(T item) {
        list.add(item);
    }

    @Override
    public T get(int index) {
        return list.get(index);
    }

    public static void main(String[] args) {
		// 範例使用
        Container<String> container = new ListContainer<>();
        container.add("Apple");
        container.add("Banana");
        container.add("Orange");

        String fruit1 = container.get(0);
        String fruit2 = container.get(1);
        String fruit3 = container.get(2);

        System.out.println(fruit1);  // 輸出: Apple
        System.out.println(fruit2);  // 輸出: Banana
        System.out.println(fruit3);  // 輸出: Orange
    }
}

在上述範例中,我們定義了一個泛型介面 Container<T>,它包含了兩個方法:add 用於新增元素,get 用於獲取指定位置的元素。然後,我們通過實現泛型介面的類 ListContainer<T>,實現了具體的容器類,這裡使用了 ArrayList 來儲存元素。在範例使用部分,我們建立了一個 ListContainer<String> 的範例,即容器中的元素型別為 String。我們可以使用 add 方法新增元素,使用 get 方法獲取指定位置的元素。

通過在介面上使用泛型,我們可以定義出具有不同型別的容器類,提高程式碼的可複用性和型別安全性。泛型介面允許我們在編譯時進行型別檢查,並提供了更好的型別約束和編碼規範。

泛型方法

泛型方法是一種在方法宣告中使用泛型型別引數的特殊方法。它允許在方法中使用引數或返回值的型別引數化,從而實現方法在不同型別上的重用和型別安全性。

泛型方法具有以下特點:

  1. 泛型方法可以在方法簽名中宣告一個或多個型別引數,使用尖括號 <T> 來表示
  2. 型別引數可以在方法內部用作方法引數型別、方法返回值型別、區域性變數型別

方法泛型化要比將整個類泛型化更清晰易懂,所以在日常使用中請儘可能的使用泛型方法。

以下展示泛型方法的範例:

public class GenericMethodExample {
    // 帶返回值的泛型方法
    public static <T> T getFirstElement(T[] array) {
        if (array != null && array.length > 0) {
            return array[0];
        }
        return null;
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3, 4, 5};
        String[] strings = {"Hello", "World"};

        System.out.println("First element in intArray: " + getFirstElement(intArray));
        System.out.println("First element in strings: " + getFirstElement(strings));
    }
}

可以看到通過泛型方法,讓 getFirstElement() 更具備通用性,無需為每個不同的型別編寫單獨的獲取方法。

再來看一個帶可變引數的泛型方法:

public class GenericMethodExample {
    // 帶返回值的泛型方法,接受變長參數列
    public static <T> List<T> createList(T... elements) {
        List<T> list = new ArrayList<>();
        for (T element : elements) {
            list.add(element);
        }
        return list;
    }

    public static void main(String[] args) {
        List<String> stringList = createList("Apple", "Banana", "Orange");
        List<Integer> intList = createList(1, 2, 3, 4, 5);

        System.out.println("String List: " + stringList);    // 輸出: String List: [Apple, Banana, Orange]
        System.out.println("Integer List: " + intList);      // 輸出: Integer List: [1, 2, 3, 4, 5]
    }
}

泛型資訊的擦除

當你深入瞭解泛型的時候,你會發現它沒有你想象的那麼安全,它只是編譯過程的語法糖,因為泛型並不是 Java 語言的特性,而是後期加入的功能特性,屬於編譯器層面的功能,而且由於要相容舊版本的緣故,所以 Java 無法實現真正的泛型。

泛型擦除是指在編譯時期,泛型型別引數會被擦除或替換為它們的上界或限定型別。這是由於Java中的泛型是通過型別擦除來實現的,編譯器在生成位元組碼時會將泛型資訊擦除,以確保與舊版本的Java程式碼相容。

以下是一個程式碼範例,展示了泛型擦除的效果:

public class GenericErasureExample {

    public static void main(String[] args) {
        // 定義一個 String 型別的集合
        List<String> stringList = new ArrayList<>();
        stringList.add("Hello");
        stringList.add("World");

        // 定義一個 Integer 型別的集合
        List<Integer> intList = new ArrayList<>();
        intList.add(10);
        intList.add(20);

        // 你無法通過反射獲取泛型的型別引數,因為泛型資訊會在編譯時被擦除
        System.out.println(stringList.getClass());   // 輸出: class java.util.ArrayList
        System.out.println(intList.getClass());      // 輸出: class java.util.ArrayList

        // 原本不同的型別,輸出結果卻相等
        System.out.println(stringList.getClass() == intList.getClass());    // 輸出: true

        // 使用原始型別List,可以繞過編譯器的型別檢查,但會導致型別轉換錯誤
        List rawList = stringList;
        rawList.add(30); // 新增了一個整數,導致型別轉換錯誤

        // 從rawList中取出元素時,會導致型別轉換錯誤
        String str = stringList.get(0);  // 型別轉換錯誤,嘗試將整數轉換為字串
    }
}

通過上述程式碼,我們演示類的泛型資訊是怎麼被擦除的,並且演示由於泛型資訊的擦除所導致的安全和轉換錯誤。這也是為什麼在泛型中無法直接使用基本型別(如 int、boolean 等),而只能使用其包裝類的原因之一。

為什麼要擦除 ?

Java 在設計泛型時選擇了擦除泛型資訊的方式,主要是為了保持與現有的非泛型程式碼的相容性,並且提供平滑的過渡。泛型是在 Java 5 中引入的,泛型型別引數被替換為它們的上界或限定型別,這樣可以確保舊版本的 Java 虛擬機器器仍然可以載入和執行這些類。

儘管泛型擦除帶來了一些限制,如無法在執行時獲取泛型型別引數的具體型別等,但通過型別萬用字元、反射和其他技術,仍然可以在一定程度上處理泛型型別的資訊。擦除泛型資訊是 Java 泛型的設計妥協,為了在保持向後相容性和型別安全性的同時,提供了一種靈活且高效的泛型機制。

擦除會引發哪些問題 ?

設計的本質就是權衡,Java 設計者為了相容性不得已選擇的擦除泛型資訊的方式,雖然完成了對歷史版本的相容,但付出的代價也是顯著的,擦除泛型資訊對於 Java 程式碼可能引發以下問題:

  1. 無法在執行時獲取泛型型別引數的具體型別:由於擦除泛型資訊,無法在執行時獲取泛型型別引數的具體型別。(如上所示)
  2. 型別轉換和型別安全性:擦除泛型資訊可能導致型別轉換錯誤和型別安全性問題。(如上所示)
  3. 無法建立具體的泛型型別範例:由於擦除泛型資訊,無法直接建立具體的泛型型別的範例。例如,無法使用 new T() 的方式
  4. 與原始型別的混淆:擦除泛型資訊可能導致與原始型別的混淆。並且泛型無法使用基本資料型別,只能依賴自動拆箱和裝箱機制

Class 資訊丟失

這是一段因為擦除導致沒有任何意義的程式碼:

public class ArrayMaker<T> {

    private Class<T> kind;

    public ArrayMaker(Class<T> kind) {
        this.kind = kind;
    }

    @SuppressWarnings("unchecked")
    T[] create(int size) {
        return (T[]) java.lang.reflect.Array.newInstance(kind, size);
    }

    public static void main(String[] args) {
        ArrayMaker<String> stringMaker = new ArrayMaker<>(String.class);
        String[] stringArray = stringMaker.create(10);
        System.out.println(Arrays.toString(stringArray));
    }
}

輸出結果:

[null, null, null, null, null, null, null, null, null, null]

泛型邊界

泛型邊界(bounds)是指對泛型型別引數進行限定,以指定其可以接受的型別範圍。泛型邊界可以通過指定上界(extends)或下界(super)來實現。泛型邊界允許我們在泛型程式碼中對型別引數進行限制,它們有助於確保在使用泛型類或方法時,只能使用符合條件的型別。

泛型邊界的使用場景包括:

  1. 型別限定:當我們希望泛型型別引數只能是特定型別或特定型別的子類時,可以使用泛型邊界。
  2. 呼叫特定型別的方法:通過泛型邊界,我們可以在泛型類或方法中呼叫特定型別的方法,存取其特定的屬性。
  3. 擴充套件泛型型別的功能:通過泛型邊界,我們可以限制泛型型別引數的範圍,以擴充套件泛型型別的功能。

上界(extends)

用於設定泛型型別引數的上界,即,型別引數必須是特定型別或該型別的子類,範例

public class MyExtendsClass<T extends Number> {
    
    public static void main(String[] args) {
        MyExtendsClass<Integer> integerMyExtendsClass = new MyExtendsClass<>();  // 可以,因為 Integer 是 Number 的子類
        MyExtendsClass<Double> doubleMyExtendsClass = new MyExtendsClass<>();   // 可以,因為 Double 是 Number 的子類
//        MyClass<String> myStringClass = new MyClass<>(); // 編譯錯誤,因為 String 不是 Number 的子類
    }
}

在泛型方法中,extends 關鍵字在泛型的讀取模式(Producer Extends,PE)中常用到。比如,一個方法返回的是 List<? extends Number>,你可以確定這個 List 中的元素都是 Number 或其子類,可以安全地讀取為 Number,但不能向其中新增任何元素(除了 null),範例:

public void doSomething(List<? extends Number> list) {
    Number number = list.get(0); // 可以讀取
//        list.add(3); // 編譯錯誤,不能寫入
}

下界(super)

用於設定型別引數的下界,即,型別引數必須是特定型別或該型別的子類。範例:

    public void addToMyList(List<? super Number> list) {
        Object o1 = new Object();
        list.add(3);  // 可以,因為 Integer 是 Number 的子類
        list.add(3.14); // 可以,因為 Double 是 Number 的子類
//      list.add("String");     // 編譯錯誤,因為 String 不是 Number 的子類
    }

在泛型方法中,super 關鍵字在泛型的寫入模式(Consumer Super,CS)中常用到。比如,一個方法引數的型別是 List<? super Integer>,你可以向這個 List 中新增 Integer 或其子類的物件,但不能從中讀取具體型別的元素(只能讀取為 Object),範例:

public void doSomething(List<? super Integer> list) {
    list.add(3);        // 型別符合,可以寫入
//  Integer number = list.get(0);     // 編譯錯誤,不能讀取具體型別
    Object o = list.get(0);     // 可以讀取 Object
}

熟練和靈活的運用 PECS 原則(Producer Extends, Consumer Super)我們也可以輕鬆實現 Collection 裡面的通用型別集合的 Copy 方法,範例:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T t : src) {
        dest.add(t);
    }
}

public static void main(String[] args) {
    List<Object> objectList = new ArrayList<>();
    List<Integer> integerList = Arrays.asList(1, 2, 3);

    copy(objectList, integerList);

    System.out.println(objectList);     // [1, 2, 3]
}

記住,無論是 extends 還是 super,它們都只是對編譯時型別的約束,實際的執行時型別資訊在型別擦除過程中已經被刪除了。

無界(?)

無界萬用字元 <?> 是一種特殊的型別引數,可以接受任何型別。它常被用在泛型程式碼中,當程式碼可以工作在不同型別的物件上,並且你可能不知道或者不關心具體的型別是什麼。你可以使用它,範例:

public static void printList(List<?> list) {
    for (Object elem : list)
        System.out.println(elem + " ");
    System.out.println();
}

public static void main(String[] args) {
    List<Integer> li = Arrays.asList(1, 2, 3, 4, 5);
    List<String> ls = Arrays.asList("one", "two", "three");
    printList(li);
    printList(ls);
}

那麼,問題來了。

那我為什麼不直接使用 Object ? 而要使用 <?> 無界萬用字元 ?

它們好像都可以容納任何型別的物件。但實際上,List<Object>List<?> 在型別安全性上有很大的不同。

例如,List<Object> 是一個具體型別,你可以向 List<Object> 中新增任何型別的物件。但是,List<Object> 不能接受其他型別的 List,例如 List<String>List<Integer>

相比之下,List<?> 是一個萬用字元型別,表示可以是任何型別的 List。你不能向 List<?> 中新增任何元素(除了 null),因為你並不知道具體的型別,但你可以接受任何型別的 List,包括 List<Object>List<String>List<Integer> 等等。

範例程式碼:

public static void printListObject(List<Object> list) {
    for (Object obj : list)
        System.out.println(obj);
}

public static void printListWildcard(List<?> list) {
    for (Object obj : list)
        System.out.println(obj);
}

public static void main(String[] args) {
    List<String> stringList = Arrays.asList("Hello", "World");

    printListWildcard(stringList); // 有效
    // printListObject(stringList); // 編譯錯誤
}

因此,當你需要編寫能接受任何型別 List 的程式碼時,應該使用 List<?> 而不是 List<Object>

目前存在的問題

在 Java 引入泛型之前,已經有大量的 Java 程式碼在生產環境中執行。為了讓這些程式碼在新版本的 Java 中仍然可以執行,Java 的設計者選擇了一種叫做 「型別擦除」 的方式來實現泛型,這樣就不需要改變 JVM 和已存在的非泛型程式碼。

但這樣的設計解決了向後相容的問題,但也引入很多問題需要大多數的 Java 程式設計師來承擔,例如:

  1. 型別擦除:這是Java泛型中最主要的限制。這意味著在執行時你不能查詢一個泛型物件的真實型別
  2. 不能範例化泛型型別的類:你不能使用 new T()new E()這樣的語法來建立泛型型別的物件,還是因為型別被擦除
  3. 不能使用基本型別作為型別引數:因為是編譯器的語法糖,所以只能使用包裝型別如 IntegerDouble 等作為泛型型別引數
  4. 萬用字元的使用可能會導致程式碼複雜:如 ? extends T? super T 在理解和應用時需要小心
  5. 因為型別擦除,泛型類不能繼承自或者實現同一泛型介面的不同引數化形式

儘管 Java 的泛型有這些缺點,但是它仍然是一個強大和有用的工具,可以幫助我們編寫更安全、更易讀的程式碼。

總結

在泛型出現之前,集合類庫並不能在編譯時期檢查插入集合的物件型別是否正確,只能在執行時期進行檢查,這種情況下一旦出錯就會在執行時丟擲一個型別轉換異常。這種執行時錯誤的出現對於開發者而言,既不友好,也難以定位問題。泛型的引入,讓開發者可以在編譯時期檢查型別,增加了程式碼的安全性。並且可以編寫更為通用的程式碼,提高了程式碼的複用性。

然而,泛型設計並非完美,主要的問題出在型別擦除上,為了保持與老版本的相容性所做的妥協。因為型別擦除,Java 的泛型喪失了一些強大的功能,例如執行時型別查詢,建立泛型陣列等。

儘管 Java 泛型存在一些限制,但是 Java 語言仍然在不斷的發展中,例如在 Java 10 中,引入了區域性變數型別推斷的特性,使得在使用泛型時可以更加方便。對於未來,Java 可能會在泛型方面進行更深入的改進。