Java™ 教程(型別擦除)

博弈發表於2019-01-19

型別擦除

泛型被引入到Java語言中,以便在編譯時提供更嚴格的型別檢查並支援通用程式設計,為了實現泛型,Java編譯器將型別擦除應用於:

  • 如果型別引數是無界的,則用它們的邊界或Object替換泛型型別中的所有型別引數,因此,生成的位元組碼僅包含普通的類、介面和方法。
  • 如有必要,插入型別轉換以保持型別安全。
  • 生成橋接方法以保留擴充套件泛型型別中的多型性。

型別擦除確保不為引數化型別建立新類,因此,泛型不會產生執行時開銷。

泛型型別擦除

在型別擦除過程中,Java編譯器將擦除所有型別引數,並在型別引數有界時將其每一個替換為第一個邊界,如果型別引數為無界,則替換為Object

考慮以下表示單連結串列中節點的泛型類:

public class Node<T> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

因為型別引數T是無界的,所以Java編譯器用Object替換它:

public class Node {

    private Object data;
    private Node next;

    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Object getData() { return data; }
    // ...
}

在以下示例中,泛型Node類使用有界型別引數:

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

Java編譯器將有界型別引數T替換為第一個邊界類Comparable

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

泛型方法擦除

Java編譯器還會擦除泛型方法引數中的型別引數,考慮以下泛型方法:

// Counts the number of occurrences of elem in anArray.
//
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

因為T是無界的,所以Java編譯器用Object替換它:

public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

假設定義了以下類:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

你可以編寫一個泛型方法來繪製不同的形狀:

public static <T extends Shape> void draw(T shape) { /* ... */ }

Java編譯器將T替換為Shape

public static void draw(Shape shape) { /* ... */ }

型別擦除和橋接方法的影響

有時型別擦除會導致你可能沒有預料到的情況,以下示例顯示瞭如何發生這種情況,該示例(在橋接方法中描述)顯示了編譯器有時如何建立一個稱為橋接方法的合成方法,作為型別擦除過程的一部分。

給出以下兩個類:

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 mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
n.setData("Hello");     
Integer x = mn.data;    // Causes a ClassCastException to be thrown.

型別擦除後,此程式碼變為:

MyNode mn = new MyNode(5);
Node n = (MyNode)mn;         // A raw type - compiler throws an unchecked warning
n.setData("Hello");
Integer x = (String)mn.data; // Causes a ClassCastException to be thrown.

以下是程式碼執行時發生的情況:

  • n.setData("Hello")導致方法setData(Object)在類MyNode的物件上執行(MyNode類從Node繼承了setData(Object))。
  • setData(Object)的方法體中,n引用的物件的data欄位被分配給String
  • 通過mn引用的同一物件的data欄位可以被訪問,並且應該是一個整數(因為mnMyNode,它是Node<Integer>)。
  • 嘗試將String分配給Integer會導致Java編譯器在賦值時插入的轉換中出現ClassCastException

橋接方法

在編譯擴充套件引數化類或實現引數化介面的類或介面時,編譯器可能需要建立一個合成方法,稱為橋接方法,作為型別擦除過程的一部分,你通常不需要擔心橋接方法,但如果出現在堆疊跟蹤中,你可能會感到困惑。

在型別擦除之後,Node和MyNode類變為:

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 {

    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

在型別擦除之後,方法簽名不匹配,Node方法變為setData(Object),MyNode方法變為setData(Integer),因此,MyNodesetData方法不會覆蓋NodesetData方法。

為了解決這個問題並在型別擦除後保留泛型型別的多型性,Java編譯器生成一個橋接方法以確保子型別按預期工作,對於MyNode類,編譯器為setData生成以下橋接方法:

class MyNode extends Node {

    // Bridge method generated by the compiler
    //
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

如你所見,橋接方法與型別擦除後的Node類的setData方法具有相同的方法簽名,委託給原始的setData方法。

非具體化型別

型別擦除部分討論編譯器移除與型別引數和型別實參相關的資訊的過程,型別擦除的結果與變數引數(也稱為varargs)方法有關,該方法的varargs形式引數具有非具體化的型別,有關varargs方法的更多資訊,請參閱將資訊傳遞給方法或建構函式任意數量的引數部分。

可具體化型別是型別資訊在執行時完全可用的型別,這包括基元、非泛型型別、原始型別和無界萬用字元的呼叫。

非具體化型別是指在編譯時通過型別擦除移除資訊的型別,即未定義為無界萬用字元的泛型型別的呼叫,非具體化型別在執行時不具有所有可用的資訊。非具體化型別的例子有List<String>List<Number>,JVM無法在執行時區分這些型別,正如對泛型的限制所示,在某些情況下不能使用非具體化型別:例如,在instanceof表示式中,或作為陣列中的元素。

堆汙染

當引數化型別的變數引用不是該引數化型別的物件時,會發生堆汙染,如果程式執行某些操作,在編譯時產生未經檢查的警告,則會出現這種情況。如果在編譯時(在編譯時型別檢查規則的限制內)或在執行時,無法驗證涉及引數化型別(例如,強制轉換或方法呼叫)的操作的正確性,將生成未經檢查的警告,例如,在混合原始型別和引數化型別時,或者在執行未經檢查的強制轉換時,會發生堆汙染。

在正常情況下,當所有程式碼同時編譯時,編譯器會發出未經檢查的警告,以引起你對潛在堆汙染的注意,如果單獨編譯程式碼的各個部分,則很難檢測到堆汙染的潛在風險,如果確保程式碼在沒有警告的情況下編譯,則不會發生堆汙染。

具有非具體化形式引數的Varargs方法的潛在漏洞

包含vararg輸入引數的泛型方法可能會導致堆汙染。

考慮以下ArrayBuilder類:

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;     // Valid
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);       // ClassCastException thrown here
  }

}

以下示例HeapPollutionExample使用ArrayBuiler類:

public class HeapPollutionExample {

  public static void main(String[] args) {

    List<String> stringListA = new ArrayList<String>();
    List<String> stringListB = new ArrayList<String>();

    ArrayBuilder.addToList(stringListA, "Seven", "Eight", "Nine");
    ArrayBuilder.addToList(stringListB, "Ten", "Eleven", "Twelve");
    List<List<String>> listOfStringLists =
      new ArrayList<List<String>>();
    ArrayBuilder.addToList(listOfStringLists,
      stringListA, stringListB);

    ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
  }
}

編譯時,ArrayBuilder.addToList方法的定義產生以下警告:

warning: [varargs] Possible heap pollution from parameterized vararg type T

當編譯器遇到varargs方法時,它會將varargs形式引數轉換為陣列,但是,Java程式語言不允許建立引數化型別的陣列,在方法ArrayBuilder.addToList中,編譯器將varargs形式引數T...元素轉換為形式引數T[]元素,即陣列,但是,由於型別擦除,編譯器會將varargs形式引數轉換為Object[]元素,因此,存在堆汙染的可能性。

以下語句將varargs形式引數l分配給Object陣列objectArgs

Object[] objectArray = l;

這種語句可能會引入堆汙染,與varargs形式引數l的引數化型別匹配的值可以分配給變數objectArray,因此可以分配給l,但是,編譯器不會在此語句中生成未經檢查的警告,編譯器在將varargs形式引數List<String> ... l轉換為形式引數List[] l時已生成警告,此語句有效,變數l的型別為List[],它是Object[]的子型別。

因此,如果將任何型別的List物件分配給objectArray陣列的任何陣列元件,編譯器不會發出警告或錯誤,如下所示:

objectArray[0] = Arrays.asList(42);

此語句使用包含一個Integer型別的物件的List物件分配objectArray陣列的第一個陣列元件。

假設你使用以下語句呼叫ArrayBuilder.faultyMethod

ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));

在執行時,JVM在以下語句中丟擲ClassCastException

// ClassCastException thrown here
String s = l[0].get(0);

儲存在變數l的第一個陣列元件中的物件具有List<Integer>型別,但此語句需要一個List<String>型別的物件。

防止來自使用非具體化的形式引數的Varargs方法的警告

如果宣告一個具有引數化型別引數的varargs方法,並確保方法體不會因為對varargs形式引數的不正確處理而丟擲ClassCastException或其他類似異常,你可以通過向靜態和非構造方法宣告新增以下註解來阻止編譯器為這些型別的varargs方法生成的警告:

@SafeVarargs

@SafeVarargs註解是方法合約的文件部分,這個註解斷言該方法的實現不會不正確地處理varargs形式引數。

儘管不太可取,但通過在方法宣告中新增以下內容來抑制此類警告也是可能的:

@SuppressWarnings({"unchecked", "varargs"})

但是,此方法不會抑制從方法的呼叫地點生成的警告,如果你不熟悉@SuppressWarnings語法,請參閱註解


上一篇:泛型萬用字元使用指南

下一篇:泛型的限制

相關文章