重溫Java泛型,帶你更深入地理解它,更好的使用它!

鍋外的大佬發表於2020-11-12

1. 引言

jdk5.0中引入了Java泛型,目的是減少錯誤,並在型別上新增額外的抽象層。
本文將簡要介紹Java中的泛型、泛型背後的目標以及如何使用泛型來提高程式碼的質量。

2. 為什麼要用泛型?

設想一個場景,我們希望用Java建立一個列表來儲存Integer;程式碼可能會寫成這樣:

List list = new LinkedList();
list.add(new Integer(1)); 
Integer i = list.iterator().next();

令人驚訝的是,編譯器會提示最後一行。它不知道返回的資料型別是什麼。因此,編譯器提示需要進行顯式轉換:

Integer i = (Integer) list.iterator.next();

沒有任何約定可以保證列表的返回型別是整數。定義的列表可以包含任何物件。我們只知道我們是通過檢查上下文來檢索列表的。在檢視型別時,它只能保證它是一個Object,因此需要顯式轉換來確保型別是安全的。

這種轉換可能會令人感到聒噪,我們明明知道這個列表中的資料型別是整數。轉換的話,也把我們的程式碼搞得亂七八糟。如果程式設計師在顯式轉換中出錯,則可能會導致丟擲與 型別相關的執行時錯誤

如果程式設計師能夠表達他們使用特定型別的意圖,並且編譯器能夠確保這種型別的正確性,那麼這將更加容易。

這就是泛型背後的核心思想。

我們將前面程式碼段的第一行修改為:

List<Integer> list = new LinkedList<>();

通過新增包含型別的菱形運算子<>,我們將此列表的特化範圍縮小到 Integer 型別,即指定將儲存在列表中的型別。編譯器可以在編譯時強制執行該型別。

在較小的程式中,這看起來像是一個微不足道的新增。但是在較大的程式中,這可以增加顯著的健壯性並使程式更易於閱讀。

3. 泛型方法

泛型方法是用單個方法宣告編寫的方法,可以用不同型別的引數呼叫。編譯器將確保所用型別的正確性。以下是泛型方法的一些屬性:

  • 泛型方法在方法宣告的返回型別之前有一個型別引數(包裹型別的菱形運算子)

  • 型別引數可以有界(邊界將在本文後面解釋)

  • 泛型方法可以具有不同的型別引數,這些引數在方法簽名中用逗號分隔

  • 泛型方法的方法體與普通方法一樣

定義將陣列轉換為列表的泛型方法的示例:

public <T> List<T> fromArrayToList(T[] a) {   
    return Arrays.stream(a).collect(Collectors.toList());
}

在前面的示例中,方法宣告中的 <T>表示該方法將處理泛型型別 T。即使方法返回的是void,也需要這樣做。
如上所述,方法可以處理多個泛型型別,在這種情況下,所有泛型型別都必須新增到方法宣告中,例如,如果我們要修改上面的方法來處理型別 T 和型別 G ,應該這樣寫:

public static <T, G> List<G> fromArrayToList(T[] a, Function<T, G> mapperFunction) {
    return Arrays.stream(a)
      .map(mapperFunction)
      .collect(Collectors.toList());
}

我們正在傳遞一個函式,該函式將具有T型別元素的陣列轉換為包含G型別元素的列表。例如,將 Integer 轉換為其 String 表示形式:

@Test
public void givenArrayOfIntegers_thanListOfStringReturnedOK() {
    Integer[] intArray = {1, 2, 3, 4, 5};
    List<String> stringList
      = Generics.fromArrayToList(intArray, Object::toString);
 
    assertThat(stringList, hasItems("1", "2", "3", "4", "5"));
}

Oracle建議使用大寫字母表示泛型型別,並選擇更具描述性的字母來表示形式型別,例如在Java集合中,T 用於型別,K 表示鍵,V 表示值。

3.1.泛型邊界

如前所述,型別引數可以是有界的。有界意味著“限制”,我們可以限制方法可以接受的型別。

例如,可以指定一個方法接受一個型別及其所有子類(上限)或一個型別所有它的超類(下限)。

要宣告上界型別,我們在型別後面使用關鍵字extends,後跟要使用的上限。例如:

public <T extends Number> List<T> fromArrayToList(T[] a) {
    ...
}

這裡使用關鍵字extends表示型別 T 擴充套件類的上限,或者實現介面的上限。

3.2. 多個邊界

型別還可以有多個上界,如下所示:

<T extends Number & Comparable>

如果 T 擴充套件的型別之一是類(即Number),則必須將其放在邊界列表的第一位。否則,將導致編譯時錯誤。

4. 使用萬用字元

萬用字元在Java中用問號““ 表示,它們是用來指一種未知的型別。萬用字元在使用泛型時特別有用,可以用作引數型別,但首先要考慮的是一個重要的註釋。

眾所周知,Object是所有Java類的超型別,但是,Object的集合不是任何集合的超型別。(可能有點繞,大家好好細品一下)

例如,List<Object>不是 List<String>的超型別,將List<Object>型別的變數賦值給List<String>型別的變數將導致編譯器錯誤。

這是為了防止在將異構型別新增到同一集合時可能發生的衝突。

相同的規則適用於型別及其子型別的任何集合。看看這個例子:

public static void paintAllBuildings(List<Building> buildings) {
    buildings.forEach(Building::paint);
}

如果我們設想一個子型別Building,例項House,那麼我們不能將此方法與House列表一起使用,即使HouseBuilding的子型別。如果需要將此方法與型別構建及其所有子型別一起使用,則有界萬用字元可以實現以下功能:

public static void paintAllBuildings(List<? extends Building> buildings) {
    ...
}

現在,這個方法可以處理Building型別及其所有子型別。這稱為上界萬用字元,其中型別Building是上界。

萬用字元也可以使用下限指定,其中未知型別必須是指定型別的超型別。可以使用super關鍵字後跟特定型別來指定下限,例如,<? super T>表示未知型別,它是 T(=T及其所有父類)的超類。

5. 型別擦除

泛型被新增到Java中以確保型別安全,並確保泛型不會在執行時造成開銷,編譯器在編譯時對泛型應用一個名為type erasure的程式。

型別擦除刪除所有型別引數,並將其替換為它們的邊界,如果型別引數是無界的,則替換為Object。因此,編譯後的位元組碼只包含普通的類、介面和方法,從而確保不會生成新的型別。在編譯時對Object型別也應用了正確的強制轉換。
以下是型別擦除的一個示例:

public <T> List<T> genericMethod(List<T> list) {
    return list.stream().collect(Collectors.toList());
}

使用型別擦除,無界型別T將替換為Object,如下所示:

// for illustration
public List<Object> withErasure(List<Object> list) {
    return list.stream().collect(Collectors.toList());
}
 
// which in practice results in
public List withErasure(List list) {
    return list.stream().collect(Collectors.toList());
}

如果型別是有界的,則在編譯時該型別將替換為繫結:

public <T extends Building> void genericMethod(T t) {
    ...
}

編譯後會發生變化:

public void genericMethod(Building t) {
    ...
}

6. 泛型和原始資料型別

Java中泛型的一個限制是型別引數不能是基本型別

例如,以下內容無法編譯:

List<int> list = new ArrayList<>();
list.add(17);

為了理解原始資料型別為什麼不起作用,只需記住 泛型是編譯時特性,這意味著型別將會被擦除,所有泛型型別都實現為 Object 類。
舉一個例子,讓我們看看列表的 add 方法:

List<Integer> list = new ArrayList<>();
list.add(17);

add 方法的宣告如下:

boolean add(E e);

並將被編譯為:

boolean add(Object e);

因此,型別引數必須可轉換為Object由於基本型別不繼承自 Object,所以不能將它們用作型別引數
但是,Java為它們提供了裝箱型別,以及自動裝箱和自動拆箱:

Integer a = 17;
int b = a;

因此,如果我們想建立一個可以儲存整數的列表,我們可以使用包裝器:

List<Integer> list = new ArrayList<>();
list.add(17);
int first = list.get(0);

編譯後的程式碼相當於:

List list = new ArrayList<>();
list.add(Integer.valueOf(17));
int first = ((Integer) list.get(0)).intValue();

Java的未來版本可能允許泛型使用原始資料型別。Valhalla 工程旨在改進處理泛型的方式。其思想是實現JEP 218中描述的泛型專門化.

**7. 總結 **

Java泛型是對Java語言的一個強大的補充,因為它使程式設計師的工作更容易,也更不容易出錯。泛型在編譯時強制執行型別正確性,並且,最重要的是,能夠實現泛型演算法,而不會給我們的應用程式帶來任何額外的開銷。

如果你覺得文章還不錯,記得關注公眾號: 鍋外的大佬
劉一手的部落格

相關文章