动态

详情 返回 返回

Java 泛型詳細解析 - 动态 详情

泛型的定義

泛型類的定義

下面定義了一個泛型類 Pair,它有一個泛型參數 T

public class Pair<T> {
	private T start;
	private T end;
}

實際使用的時候就可以給這個 T 指定任何實際的類型,比如下面所示,就指定了實際類型為 LocalDate,泛型給了我們一個錯覺就是通過個這個模板類 Pair<T>,我們可以在實際使用的時候動態的派生出各種實際的類型,比如這裏的 Pair<LocalDate> 類。

Pair<LocalDate> period = new Pair<>();

泛型類的繼承

子類是一個泛型類的定義方法如下:

public class Interval<T> extend Pair<T> {}

這裏的 Interval<T> 類是一個泛型類,也可以像上面使用 Pair<T> 類一樣給它指定實際的類型。

子類是一個具體類的定義方法如下:

public class DateInterval extends Pair<LocalDate> {}

這裏的 DateInterval 類就是一個具體的類,而不再是一個泛型類了。這裏的語義是 DateInteral 類繼承了 Pair<LocalDate> 類,這裏的 Pair<LocalDate> 類也是一個具體類。但是由於 Java 的泛型實現機制,這裏會帶來多態上的一個問題,見下面的分析。

而像下面的這種定義具體類的寫法是錯誤的:

public class DateInterval<LocalDate> extends Pair<LocalDate> {}

泛型方法的定義

泛型方法定義時,類型變量放在修飾符的後面,返回值的前面。泛型方法既可以泛型類中定義,在普通類中定義。

public static <T> T genericMethod(T a) {}

這裏順便記錄一下,因為是使用擦除來實現的泛型,因此字節碼中的方法的簽名是不會包含泛型信息的。對於泛型方法會多生成一個 Signature 的屬性,用於記錄方法帶泛型信息的簽名,反編譯器也可以根據這個信息將泛型方法還原回來。
image.png
請添加圖片描述

構造函數泛型

下面的代碼定義了一個泛型類 ConstructorGeneric,它的泛型參數是 T,這個類的構造函數也是泛型的,它有一個泛型參數 X

class ConstructorGeneric<T> {
	public <X> ConstructorGeneric(X a) {}
}

創建該對象的代碼如下:

ConstructorGeneric<Number> t = new <String>ConstructorGeneric<Number>("123");

這裏 new 後面的 String 是傳給構造器的泛型 X 的,即 X 的實際類型為 String;類的範型參數是由 Number 傳遞的,即 T 的實際類型是 Number。這裏兩個都是省略,寫在這裏是為了顯示區分出兩個參數傳遞的位置。

類型變量的限定

帶單個上界限定
下面的代碼定義了一個 NatualNumber 類,它的泛型參數 T 限制為 Integer 或者 Integer 的子類。

public class NaturalNumber<T extends Integer> {
    private T n;

    public NaturalNumber(T n)  { this.n = n; }

    public boolean isEven() {
        return n.intValue() % 2 == 0;
    }
}

調用代碼如下:

// 正常
NaturalNumber<Integer> natural1 = new NaturalNumber<>(1);  

// 無法編譯,因為這裏和泛型類定義的上界不符合
NaturalNumber<Double> natualral2 = new NaturalNumber<>(1.0);

帶多個上界的限定
多個上界之間使用 & 符號進行分隔,如果多個限定中有類,則類需要排在接口後面(因為 Java 不支持多繼承,所以不存在有多個限定的類的情況)。使用時需要滿足所有的限定條件才能執行,這個校驗應該是在編譯時期做的,因為擦除之後,只會保留第一個限定界。

class A {}
interface B {}
class C extends A implements B {}
public static <T extends A & B> void test(T a) {}

public static void main(String[] args) {
	// 編譯錯誤,A 只能滿足一個上界
	test(new A());
	// 正常
	test(new C());
}

通配符

在泛型中使用 ? 表示通配符,它的語義是表示未知的類型。通配符可以用作方法的形參、字段的定義、局部變量的定義,以及有的時候作為函數的返回值。通配符不能作為實參調用泛型方法,不能創建對象,或者派生子類型。

上界通配符

當你想定義一個普通方法,這個普通方法可以處理某一類的 List 中的元素時,比如像:List<Number>List<Integer>List<Double> 時,這個時候如果你把方法的入參定義為 List<Number> 是不行的,因為在 Java 中 List<Integer> 不是 List<Number> 的子類。

public static void process(List<Number> numbers) {}

// 編譯錯誤
List<Number> numbers = new ArrayList<>();
proess(numbers);

假設 List 是 List 的子類,則可以實現如下的代碼:

List<Integer> integers = new ArrayList<>();

// 假設下面是成立的
List<Number> numbers = integers;

// 下面這句也應該是合法的,但是這違背了 intergers 只能存放 Integer 的語義
numbers.add(new Double());

從上面的例子可以看出,如果允許 List<Integer>List<Number> 的子類型,則會破壞泛型的語義,因此這在 Java 中是不允許的。

但是又實際存在上面描述的這種需求,因此 Java 提供了上界通配符的語法,則方法定義可以定義為如下:

public static void process(List<? extends Number> numbers) {
	for (Number num : numbers) {
		// do something
	}
}

// 下面的調用都是能夠正常編譯通過的
List<Number> numbers = new ArrayList<>();
process(numbers);

List<Integer> integers = new ArrayList<>();
process(integers);

List<Double> doubles = new ArrayList<>();
process(doubles);

這裏的 ? extends Number 的語義就是可以匹配 Number 或者 Number 子類的 List,需要注意的是在 Java 中的繼承(extends)和實現(implements)在這裏都用關鍵字 extends 來表示。

從這裏也可以看出,List<? extends Number> 的返回值是可以賦值給 Number 類型的。這裏可以想象一下 Listget() 方法的泛型參數 E 就變成了 ? extends Number 這個實際類型,而它表達的語義是 Number 以及 Number 的子類,因此賦值給一個 Number 類型的變量是合法的。

但是下面的代碼是不合法的:

public static void process(List<? extends Number> numbers) {
	numbers.add(new Integer());
}

這裏同樣可以想象一下 Listadd() 方法的入參的泛型參數 E 就變成了 ? extends Number 這個實際類型,它表達的語義是 Number 以及 Number 的子類,但是具體是哪個子類是無法確定的。上面的例子也解釋了它可能是 NumberIntegerDouble 等,假設它是 Double 類型,這裏放一個 Integer 類型,又違背了泛型只能放 Double 的語義,因此這裏的賦值是不合法的。

無界通配符

下面的代碼就是定義了一個 List<?> 形參的方法,這裏的 List<?> 語義是一個未知類型的 List

public static void printList(List<?> list) {}

無界通配符定義的 List 裏面的元素只能賦值給 Object 類型。這裏可以想象一下 Listget() 方法的泛型參數 E 就變成了 ? 這個實際類型,它的語義是一個未知的類型,既然是一個未知的類型那麼我只能賦值給 Object 類型的變量了。

public static void printList(List<?> list) {
	for (Object obj : list) {
		// do something
	}
}

無界通配符定義的 List 裏面只能添加 null,不能添加其它的任何類型的元素,即使是 Object 也不行,因為添加了之後就會違背泛型的語義了。

無界通配符的主要使用場景是:

  • 需要使用 Object 類中的方法
  • 使用了泛型類中不用關心泛型的方法,比如 List 中的 size()clear() 方法

下界通配符

在使用上面的上界通配時,發現了一個問題,如果一個 List 類型形參聲明為了上界通配符,是沒有辦法往這個 List 裏面添加元素的,為了解決這個問題,可以使用下界通配符,可以定義如下的方法:

public static void addNumbers(List<? super Number> list) {
	list.add(new Integer());
	list.add(new Double());
}

這裏可以想象一下這個時候 Listadd() 方法的入參的泛型參數 E 就變成了 ? super Integer 類型,它的語義是匹配 Number 以及 Number 類型的超類。根據 Java 多態的原理,這裏實際可以傳遞的類型為 Integer 以及 Integer 的子類型,因為形參聲明的是超類,實際傳遞子類的引用當然是合法的。

泛型繼承關係

泛型的繼承關係如下圖所示:

image.png

通配符捕獲

假設定義了一個無界限通配符的方法如下,這個方法會編譯錯誤,因為按照之前分析的 List<?> 中不能添加任何類型的對象,而這裏 list.get(0) 返回的是 Object 類型的對象,肯定是無法放入進去的。代碼如下:

public void foo(List<?> list) {
	list.set(0, list.get(0)); // 編譯報錯
}

為了解決這個問題這個時候就可以通過新建一個私有的泛型方法來幫助捕獲通配符的類型,這個私有的泛型方法名稱通常是原有方法加上Helper後綴,這種技巧稱為通配符捕獲。代碼如下:

pulic void foo(List<?> list) {
	// 調用這個方法的語義是告訴編譯器我不知道具體類型是什麼,
	// 但是取出來和放進去的元素類型是相同的
	fooHelper(list);
}

private <T> void fooHelper(List<T> list) {
    // 合法
	T temp = list.get(0);
	// 合法
	list.set(0, temp);
}

對於泛型方法,因為 add() 方法的入參,get() 方法返回值的泛型參數都是 T,當傳入一個 List 進來,雖然這個 List 裏面的對象實際類型不知道,但是通過泛型參數可以判斷 get() 方法返回類型和 add() 方法的入參類型都是一樣的,都是 T 捕獲到的一個實際類型 X

對於帶通配符參數的方法,因為方法的聲明沒有一個泛型參數,不能捕獲到實際的參數類型 X。那麼對於每次方法的調用編譯器都會認為是一個不同的類型。比如編譯器編譯的時候 list.set(0, xxx),這裏的入參的類型就會是 CAP#1list.get(0) 返回的類型就是 CAP#2,因為沒有一個泛型參數來告訴編譯器説 CAP#1CAP#2 是一樣的類型,因此編譯器就會認為這兩個是不同的類型,從而拒絕編譯。下圖是編譯器實際的提示信息:
image.png

image.png
從上面的圖也可以看出,第二次調用方法時,類型又變成 CAP#3CAP#4 了,這也證明了每次編譯器都會認為是一個新的類型。

實際上這裏也可以將這個私有的 Helper 方法定義為公共的,然後去掉通配符的方法。這兩種定義實際上是達到了相同的效果,但是 Java 語言規範 5.1.10 章節中更推薦採用通配符的方式定義,但它上面闡述的原因沒太看懂,但是在另外一篇博客裏面看到一個觀點感覺有點道理。
image.png

它説如果定義成一個泛型方法,那麼老的遺留的沒有用泛型的代碼調用這個方法就會產生一個警告,但是如果是使用通配符則不會有警告產生。

public static void foo1(List<?>) {}

public static <T> void foo2(List<T>) {}

// 假設老的代碼沒有用泛型
List rawList = Arrays.asList("1", "2");
// 不會產生告警
foo1(rawList);
// 會產生告警,提示未經檢查的轉換
foo2(rawList);

然而實際上 JDK 中真正的實現並沒有採用這種方式,而是直接用註解忽略了異常,直接用的原生類型來實現的。Collections 中的 reverse() 方法內部實現邏輯如下:

@SuppressWarnings({"rawtypes", "unchecked"})  
public static void reverse(List<?> list) {  
    int size = list.size();  
    if (size < REVERSE_THRESHOLD || list instanceof RandomAccess) {  
        for (int i=0, mid=size>>1, j=size-1; i<mid; i++, j--)  
            swap(list, i, j);  
    } else {  
        // instead of using a raw type here, it's possible to capture 
        // the wildcard but it will require a call to a supplementary         // private method 
        ListIterator fwd = list.listIterator();  
        ListIterator rev = list.listIterator(size);  
        for (int i=0, mid=list.size()>>1; i<mid; i++) {  
            Object tmp = fwd.next();  
            fwd.set(rev.previous());  
            rev.set(tmp);  
        }  
    }  
}

橋接方法

假設定義瞭如下代碼:

public class Node<T> {
    public T data;
    public Node(T data) { this.data = data; }
    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

泛型擦除後的實際代碼如下,注意看 MyNode 裏面的 setData() 方法並沒有重寫 Node 裏面的 setData() 方法了,因為方法簽名不一樣。這就違背了 Java 多態的語義。
Java 編譯器在編譯的時候會自動給 MyNode 生成一個橋接方法,這個方法的簽名和 Node 類裏面的一樣,然後在這個方法裏面去調用真正的 setData() 方法。
通過查看 MyNode.class 文件可以看到真的有兩個 setData() 方法存在。
image.png

方法的形參類型是 Object 類型,和 Node 類中泛型擦除後的類型相同,説明這個方法才是真正重載了 Node 類中的方法。
image.png

方法實現中調用了 MyNode 類中形參為 Integer 類型的 setData() 方法。
image.png

同時在 MyNode 類中不允許自己定義形參為 Object 類型的 setData() 方法了,如果定義了則無法編譯:
image.png

經過編譯器編譯後的代碼等效為如下的代碼:

public class Node {
    public Object data;
    public Node(Object data) { this.data = data; }
    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
    
    // 由編譯器生成的橋接方法
    // 如果手動定義了這個方法編譯器就會報錯了
    public void setData(Object data) {
        setData((Integer) data);
    }
}

泛型的侷限性

泛型不能用於基本類型

泛型是通過擦除實現的,擦除之後 ArrayList 內部是 Object[] 類型的數組,是不能存放基本類型的,因為基本類型不是 Object 類型的子類。

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

不能創建泛型類型的實例

泛型是通過擦除來實現的,所以擦除之後都會變成 new Object() (沒有指定上界的情況),而實際上我們是要創建 T 類型的實例的。

public static <T> void test(List<T> list) {
	E ele = new E();
	list.add(ele);
}

// 可以通過如下方式
public static <T> void test(List<T> list, Class<T> clazz) {
	E ele = clazz.newInstance();
	list.add(ele);
}
// 調用
List<String> list = new ArrayList<>();
test(list, String.class);

不能聲明靜態的泛型變量

泛型相當於是類的工廠,可以創建不同類型的實例。而靜態變量是所有實例共享的,如果允許聲明靜態的泛型變量,那麼不同類型的實例之間就會存在矛盾。

public class MobileDevice<T> {
	private static T os;
}

// 這兩個實例的靜態變量就會存在矛盾
MobileDevice<Smartphone> phone = new MobileDevice<>();
MobileDevice<TabletPC> pc = new MobileDevice<>();

不能使用 instanceof 判斷泛型類型

泛型是通過擦除實現的,因此 List<T>.class 在內存中是不存在的,只有 List.class,這個類型也被稱為原生類型。

// 錯誤
if (list instanceof List<String>) {
}

// 正確
if (list instanceof List) {
}

不能創建泛型數組

泛型是通過擦除實現的,如果允許聲明泛型數組,則無法實現數組在存放時會校驗數組的元素類型這個語義。

// 假設允許創建,這個數組的每個元素只允許存放 List<String> 類型的元素
Object[] stringLists = new List<String>[2]; 
// 正確執行
stringLists[0] = new ArrayList<String>(); 
// 這行應該拋出 ArrayStoreException 異常,
// 但是由於擦除,實際上和上面是一樣的,這裏違背了數組的語義
stringLists[1] = new ArrayList<Integer>();

不能創建、捕獲、拋出帶泛型的異常

// 編譯報錯
class MathException<T> extends Exception {}    

// 編譯報錯
class QueueFullException<T> extends Throwable {}

// 編譯報錯
public static <T extends Exception, J> void execute(List<J> jobs) {
    try {
        for (J job : jobs)
    } catch (T e) {  // 編譯報錯
    }
}

class Parser<T extends Exception> {
	// 這樣是允許的
	// 我覺得允許的原因是聲明瞭拋出父類,而實際拋出子類也是合法的
    public void parse(File file) throws T {  
    }
}

不能使用擦除後原生類型相同的泛型參數方法來重載

public class Example {
    // 這兩個方法擦除後的參數是一樣的,所以不能算重載
    public void print(Set<String> strSet) { }
    public void print(Set<Integer> intSet) { }
}

堆污染

當定義變長的泛型參數時,如果嘗試把一個原生類型賦值給變成泛型參數就有可能發生堆污染。堆污染的本質原因就是可以通過語法糖變長參數列表來創建泛型的的數組導致的。例如下面的代碼:

public class ArrayBuilder {
  public static <T> void addToList (List<T> listArg, T... elements) {
    for (T x : elements) {
      listArg.add(x);
    }
  }

  public static void faultyMethod(List<String>... l) {
    // 這裏編譯應該會有告警,如果忽略這個告警,則有可能帶來堆污染
    Object[] objectArray = l;   
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);     
  }
}

編譯告警中就會提示有堆污染,如下圖所示:
image.png

當編譯器遇到一個變長參數方法時,它會把它轉換為一個數組。對於 T... elements 這種參數聲明就會轉為 T[] elements,因為泛型的擦除,最終會被轉換為 Object[] elements,這裏編譯器就會認為有可能發生堆污染。

可以通過以下三種方式抑制這種警告:

  • @SuppressWarnings({"unchecked", "varargs"})
    這種方式只能抑制方法聲明時候的告警,方法調用處還是會產生告警;
    image.png
  • @SafeVarargs
    不會產生任何警告
    image.png
  • 增加 -Xlint:varags 編譯選項
    不會產生任何警告
    image.png

JVM 控制參數

顯示所有告警信息

給編譯器增加 -Xlint:unchecked ,在 Idea 中可以參考如下圖配置:
image.png

顯示更詳細的診斷信息

給編譯增加 -Xdiags: verbose 選項
image.png

顯示所有告警信息為英文

增加如下環境變量:
image.png
Idea 中可以將配置放在 vmproperties 文件中,如下圖所示:
image.png

參考

Java Generic Tutorial
Java核心技術·卷 I(原書第10版)
深入理解Java虛擬機(第3版)
When to use generic methods and when to use wild-card?
Why use a wild card capture helper method?
Capture Conv: rev/reverse - what's the point?
Difference between <? super T> and <? extends T> in Java
What is PECS (Producer Extends Consumer Super)?
Differences between copy(List<? super T> dest, List<? extends T> src) and copy(List dest, List<? extends T> src)

Add a new 评论

Some HTML is okay.