Java 泛型:理解和應用

2023-05-24

概述

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

  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 可能會在泛型方面進行更深入的改進。

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