15泛型_15.7擦除的神祕之處

15.7 擦除的神祕之處

當你開始更深人地鑽研泛型時,會發現有大量的東西初看起來是沒有意義的。例如,儘管可以聲明ArrayList.class,但是不能聲明ArrayList<Integer>.class,請考慮下面的情況:

//: generics/ErasedTypeEquivalence.java
import java.util.*;

public class ErasedTypeEquivalence {
  public static void main(String[] args) {
    Class c1 = new ArrayList<String>().getClass();
    Class c2 = new ArrayList<Integer>().getClass();
    System.out.println(c1 == c2);
  }
} /* Output:
true
*///:~

ArrayList<String>ArrayList<Integer>很容易被認爲是不同的類型。不同的類型在行爲方面肯定不同,例如,如果嘗試着將一個Integer放入ArrayList<String>,所得到的行爲(將失敗)與把一個Integer放入ArrayList<Integer>(將成功)所得到的行爲完全不同。但是上面的程序會認爲它們是相同的類型。

下面是的示例是對這個謎題的一個補充:

//: generics/LostInformation.java
import java.util.*;

class Frob {}
class Fnorkle {}
class Quark<Q> {}
class Particle<POSITION,MOMENTUM> {}

public class LostInformation {
  public static void main(String[] args) {
    List<Frob> list = new ArrayList<Frob>();
    Map<Frob,Fnorkle> map = new HashMap<Frob,Fnorkle>();
    Quark<Fnorkle> quark = new Quark<Fnorkle>();
    Particle<Long,Double> p = new Particle<Long,Double>();
    System.out.println(Arrays.toString(
      list.getClass().getTypeParameters()));
    System.out.println(Arrays.toString(
      map.getClass().getTypeParameters()));
    System.out.println(Arrays.toString(
      quark.getClass().getTypeParameters()));
    System.out.println(Arrays.toString(
      p.getClass().getTypeParameters()));
  }
} /* Output:
[E]
[K, V]
[Q]
[POSITION, MOMENTUM]
*///:~

根據JDK文檔的描述,Class.getTypeParameters()將“返回一個TypeVariable對象數組,表示有泛型聲明所聲明的類型參數…”。這好像是在暗示你可能發現參數類型的信息,但是,正如你從輸出中所看到的,你能夠發現的只是用作參數佔位符的標識符,這並非有用的信息。

因此,殘酷的現實是:
在泛型代碼內部,無法獲得任何有關泛型參數類型的信息。

因此,你可以知道諸如類型參數標識符和泛型類型邊界這類的信息——你卻無法知道用來創建某個特定實例的實際的類型參數。如果你曾經是C++程序員,那麼這個事實肯定讓你覺得很沮喪,在使用Java泛型工作時它是必須處理的最基本的間題。

Java泛型是使用擦除來實現的,這意味着當你在使用泛型時,任何具體的類型信息都被擦除了,你唯一知遒的就是你在使用一個對象。因此List<String>List<interger>在運行時事實上是相同的類型。這兩種形式都被擦除成它們的“原生”類型,即List。理解擦除以及應該如何處理它,是你在學習Java泛型時面臨的最大障礙。這也是我們在本節將要探討的內容。

15.7.1 C++的方式

下面是使用模版的C++示例,你將注意到用於參數化類型的語法十分相似,因爲Java是受C++的啓發:

//: generics/Templates.cpp
#include <iostream>
using namespace std;

template<class T> class Manipulator {
  T obj;
public:
  Manipulator(T x) { obj = x; }
  void manipulate() { obj.f(); }
};

class HasF {
public:
  void f() { cout << "HasF::f()" << endl; }
};

int main() {
  HasF hf;
  Manipulator<HasF> manipulator(hf);
  manipulator.manipulate();
} /* Output:
HasF::f()
///:~

Manipulator類存儲了一個類型T的對象,有意思的地方是manipulate()方法,它在obj上調用方f()。它怎麼能知道f()方法是爲類型參數T而存在的呢?當你實例化這個模版時,C++編譯器將進行檢查,因此在Manipulalor<HasF>被實例化的這一刻,它看到HasF擁有一個方法f()。如果情況井非如此,就會得到一個編譯期錯誤,這樣類型安全就得到了保障。

用C++編寫這種代碼很簡單,因爲當模版被實例化時,模版代碼知道其模版參數的類型。Java泛型就不同了。下面是HasF的Java版本:

//: generics/HasF.java

public class HasF {
  public void f() { System.out.println("HasF.f()"); }
} ///:~

如果我們將這個示例的其餘代碼都翻譯成Java,那麼這些代碼將不能編譯:

//: generics/Manipulation.java
// {CompileTimeError} (Won't compile)

class Manipulator<T> {
  private T obj;
  public Manipulator(T x) { obj = x; }
  // Error: cannot find symbol: method f():
  public void manipulate() { obj.f(); }
}

public class Manipulation {
  public static void main(String[] args) {
    HasF hf = new HasF();
    Manipulator<HasF> manipulator =
      new Manipulator<HasF>(hf);
    manipulator.manipulate();
  }
} ///:~

由於有了擦除,Java編譯器無法將maaipulate()必須能夠在obj上調用f()這一需求映射到HasF有f()這一事實上。爲了調用f(),我們必須協助泛型類,給定泛型類的邊界,以此告知編譯器只能接受遵循這個邊界的類型。這裏重用了extends關鍵字。由於有了邊界,下面的代碼就可以編譯了:

//: generics/Manipulator2.java

class Manipulator2<T extends HasF> {
  private T obj;
  public Manipulator2(T x) { obj = x; }
  public void manipulate() { obj.f(); }
} ///:~

邊界<T extends HasF>聲明T必須具有類型HasF或者從HasF導出的類型。如果情況確實如此,那麼就可以安全地在obj上調用f()了。

我們說泛型類型參數將擦除到它的第一個邊界(它可能會有多個邊界,稍候你就會看到),我們還捉到了類型參數的擦除。編譯器實際上會把類型參數替換爲它的擦除,就像上面的示例一樣。T擦除到了HasF,就好像在類的聲明中用HasF替換了T一樣。

你可能已經正確地觀察到,在Manipulation2.java中,泛型沒有貢獻任何好處。只需很容易地自己去執行擦除,就可以創建出沒有泛型的類:

//: generics/Manipulator3.java

class Manipulator3 {
  private HasF obj;
  public Manipulator3(HasF x) { obj = x; }
  public void manipulate() { obj.f(); }
} ///:~

這提出了很重要的一點:只有當你希望使用的類型參數比某個具體類型(以及它的所有子類型)更加“泛化”時——也就是說,當你希望代碼能夠跨多個類工作時,使用泛型纔有所幫助。因此,類型參數和它們在有用的泛型代碼中的應用,通常比簡單的類替換要更復雜。但是,不能因此而認爲<T extends HasF>形式的任何東西而都是有缺陷的。例如,如果某個類有一個返回T的方法,那麼泛型就有所幫助,因爲它們之後將返回確切的類型:

//: generics/ReturnGenericType.java

class ReturnGenericType<T extends HasF> {
  private T obj;
  public ReturnGenericType(T x) { obj = x; }
  public T get() { return obj; }
} ///:~

必須查看所有的代碼,並確定它是否“足夠複雜”到必須作用泛型的程度。
我們將在本章稍後介紹有關邊界的更多細節。

15.7.2 遷移兼容性

爲了減少潛在的關於擦除的混淆,你必須清楚地認識到這不是一個語言特性。它是Java的泛型實現中的一種折中,因爲泛型不是Java語言出現時就有的組成部分,所以這種折中是必需的。這種折中會使你痛苦,因此你需要習慣它並瞭解爲什麼它會是這樣。

如果泛型在Java 1.0中就已經是其一部分了,那麼這個特性將不會使用擦除來實現——它將使用縣體化,使類型參數保持爲第一類實體,因此你就能夠在類型參數上執行基於類型的語言操作和反射操作。你將在本章稍後看到,擦除減少了泛型的泛化性。泛型在Java中仍歸是有用的,只是不如它們本來設想的那麼有用,而原因就是擦除。

在基於擦除的實現中,泛型類型被當作第二類類型處理,即不能在某些重要的上下文環境中使用的類型。泛型類型只有在靜態類型檢查期間纔出現,在此之後,程序中的所有泛型類型都將被擦除,替換爲它們的非泛型上界。例如,諸如List<T>這樣的類型註解將被擦除爲List,而普通的類型變量在未指定邊界的情況下將被擦除爲Object。

擦除的核心動機是它使得泛化的客戶端可以用非泛化的類庫來使用,反之亦然,這經常被稱爲“遷移兼容性”。在理想情況下,當所有事物都可以同時被泛化時,我們就可以專注於此。在現實中,即使程序員只編寫泛型代碼,他們也必須處理在Java SE5之前編寫的非泛型類庫。那些類庫的作者可能從沒有想過要泛化它們的代碼,或者可能剛剛開始接觸泛型。

因此Java泛型不僅必須支特向後兼容性,即現有的代碼和類文件仍舊合法,井且繼續保持其之前的含義而且還要支持遷移兼容性,使得類庫接照它們自己的步調變爲泛型的,並且當某個類庫變爲泛型時,不會破壞依賴於它的代碼和應用程序。在決定這就是目標之後,Java設計者們和從事此問題相關工作的各個團隊決策認爲擦除是唯一可行的解決方案。通過允許非泛型代碼與泛型代碼共存,擦除使得這種向着泛型的遷移成爲可能。

例如,假設某個應用程序具有兩個類庫X和Y,並且Y還要使用類庫Z。隨着Java SE5的出現,這個應用程序和這些類庫的創建者最終可能希望遷移到泛型上。但是,當進行這種遷移時,他們有着不同動機和限制。爲了實現遷移兼容性,每個類庫和應用程序都必須與其他所有的部分是否使用了泛型無關。這樣,它們必須不具備探測其他類庫是否使用了泛型的能力。因此,某個特定的類庫使用了泛型這樣的證據必須被“擦除”。

如果沒有某種類型的遷移途徑,所有已經構建了很長時間的類庫就需要與希望遷移到Java泛型上的開發者們說再見了。但是,類庫是編程語言無可爭議的一部分,它們對生產效率會產生最重要的影響,因此這不是一種可以接受的代價。擦除是否是最佳的或者唯一的遷移途徑,還需要時間來證明。

15.7.3 擦除的問題

因此,擦除主要的正當理由是從非泛化代碼到泛化代碼的轉變過程,以及在不破壞現有類庫的情況下,將泛型融入Java語言。擦除使得現有的非泛型客戶端代碼能夠在不改變的情況下繼續使用,直至客戶端準備好用泛型重寫這些代碼。這是一個崇高的動機,因爲它不會突然間破壞所有現有的代碼。

擦除的代價是顯著的。泛型不能用於顯式地引用運行時類型的操作之中,例如轉型、instanceof操作和new表達式。因爲所有關於參數的類型信息都丟失了。無論何時,當你在編寫泛型代碼時,必須時刻提醒自己,你只是看起來好像擁有有關參數的類型信息而已。因此,如果你編寫了下面這樣的代碼段:

class Foo<T> {
  T var;
}

那麼,看起來當你在創建Foo實例時:

Foo<Cat> f = new Foo<Cat>();

class Foo中的代碼應該知道現在工作於Cat之上,而泛型語法也在強烈暗示:在整個類中的各個地方,類型T都在被替換。但是事實並非如此,無論何時,當你在編寫這個類的代碼時,必須提醒自己:“不,它只是一個Object。”

另外,擦除和遷移兼容性意味着,使用泛型並不是強制的,儘管你可能希望這樣:

//: generics/ErasureAndInheritance.java

class GenericBase<T> {
  private T element;
  public void set(T arg) { arg = element; }
  public T get() { return element; }
}

class Derived1<T> extends GenericBase<T> {}

class Derived2 extends GenericBase {} // No warning

// class Derived3 extends GenericBase<?> {}
// Strange error:
//   unexpected type found : ?
//   required: class or interface without bounds    

public class ErasureAndInheritance {
  @SuppressWarnings("unchecked")
  public static void main(String[] args) {
    Derived2 d2 = new Derived2();
    Object obj = d2.get();
    d2.set(obj); // Warning here!
  }
} ///:~

Derived2繼承自GenericBase,但是沒有任何泛型參數,而編譯器不會發出任何警告。警告在Set()被調用時纔會出現。

爲了關閉警告,Java提供了一個註解,我們可以在列表中看到它(這個註解在Java SE5之前的版本中不支持):

    @suppressWarnings("unchecked")

注意,這個往解被放置在可以產生這類警告的方法之上,而不是整個類上。當你要關閉警告時,最好是儘量地“聚焦”,這樣就不會因爲過於寬泛地關閉警告,而導致意外地遮蔽掉真正的問題。

可以推斷,Derived3產生的錯誤意味着編譯器期望得到一個原生基類。

當你希望將類型參數不要僅僅當作Object處理時,就需要付出額外努力來管理邊界,並且與在C++, Ada和Eiffel這樣的語言中獲得參數化類型相比,你需要付出多得多的努力來獲得少得多的回報。這並不是說,對於大多數的編程問題而言,這些語言通常都會比Java更得心應手,這只是說,它們的參數化類型機制比Java的更靈活、更強大。

15.7.4 邊界處的動作

正是因爲有了擦除,我發現泛型最令人困惑的方面源自這樣一個事實,即可以表示沒有任何意義的事物。例如:

//: generics/ArrayMaker.java
import java.lang.reflect.*;
import java.util.*;

public class ArrayMaker<T> {
  private Class<T> kind;
  public ArrayMaker(Class<T> kind) { this.kind = kind; }
  @SuppressWarnings("unchecked")
  T[] create(int size) {
    return (T[])Array.newInstance(kind, size);
  }
  public static void main(String[] args) {
    ArrayMaker<String> stringMaker =
      new ArrayMaker<String>(String.class);
    String[] stringArray = stringMaker.create(9);
    System.out.println(Arrays.toString(stringArray));
  }
} /* Output:
[null, null, null, null, null, null, null, null, null]
*///:~

即使kind被存儲爲Class<T>,擦除也意味着它實際將被存儲爲Class,沒有任何參數。因此,當你在使用它時,例如在創建數組時,Array.newInstance()實際上並未擁有kind所蘊含的類型信息,因此這不會產生具體的結果。所以必須轉型,這將產生一條令你無法滿意的警告。

注意,對於在泛型中創建數組,使用Array.newInstance()是推薦的方式。

如果我們要創建一個容器而不是數組,情況就有些不同了:

//: generics/ListMaker.java
import java.util.*;

public class ListMaker<T> {
  List<T> create() { return new ArrayList<T>(); }
  public static void main(String[] args) {
    ListMaker<String> stringMaker= new ListMaker<String>();
    List<String> stringList = stringMaker.create();
  }
} ///:~

編譯器不會給出任何警告,儘管我們(從擦除中)知道在create()內部的new ArrayList<T>中的<T>被移除了——在運行時,這個類的內部沒有任何<T>,因此這看起來毫無意義。但是如果你遵從這種思路,並將這個表達式改爲new ArrayList(),編譯器就會給出警告。

在本例中,這是否真的毫無意義呢?如果返回list之前,將某些對象放入其中。就像下面這樣,情況又會如何呢?

//: generics/FilledListMaker.java
import java.util.*;

public class FilledListMaker<T> {
  List<T> create(T t, int n) {
    List<T> result = new ArrayList<T>();
    for(int i = 0; i < n; i++)
      result.add(t);
    return result;
  }
  public static void main(String[] args) {
    FilledListMaker<String> stringMaker =
      new FilledListMaker<String>();
    List<String> list = stringMaker.create("Hello", 4);
    System.out.println(list);
  }
} /* Output:
[Hello, Hello, Hello, Hello]
*///:~

即使編譯器無法知道有關create()中的T的任何信息,但是它仍舊可以在編譯期確保你放置到result中的對象具有T類型,使共適合ArrayList<T>。因此,即使擦除在方法或類內部移除了有關實際類型的信息,編譯器仍舊可以確保在方法或類中使用的類型的內部一致性。

因爲擦除在方法體中移除了類型信息,所以在運行時的問題就是邊界:即對象進入和離開方法的地點。這些正是編譯器在編譯期執行類型檢查並插入轉型代碼的地點。請考慮下面的非泛型示例:

//: generics/SimpleHolder.java

public class SimpleHolder {
  private Object obj;
  public void set(Object obj) { this.obj = obj; }
  public Object get() { return obj; }
  public static void main(String[] args) {
    SimpleHolder holder = new SimpleHolder();
    holder.set("Item");
    String s = (String)holder.get();
  }
} ///:~

如果用Javap -c SimpleHolder反編譯這個類,就可以得到下面的(經過編輯的)內容:

public void set (java.lang.Object):
0: aload_0
1: aload_1
2: putfield #2; //Field obj:Object
5: return

public java.lang.Object get():
0: aload_0
1: getfield #2; //Field obj:Object
4: areturn

public static void main(java.lang.String[]):
0: new #3; //class SimpleHolder
3: dup
4: invokespecial #4; //Method "<init>:()v
7: a-store_1
8: aload_ 1
9: ldc #5; //String Item;
11: invokevitrual #6; //Method set:(Object;)V
14: aload_1
15: invokevirtual #7: //Method get:()Object;
18: checkcast #8; //class java/lang/String
21: astore_2
22: return

get()和set()方法將直接存儲和產生值,而轉型是在調用get()的時候接受檢查的。

現在將泛型合併到上面的代碼中:

//: generics/GenericHolder.java

public class GenericHolder<T> {
  private T obj;
  public void set(T obj) { this.obj = obj; }
  public T get() { return obj; }
  public static void main(String[] args) {
    GenericHolder<String> holder =
      new GenericHolder<String>();
    holder.set("Item");
    String s = holder.get();
  }
} ///:~

從get()返回之後的轉型消失了,但是我們還知道傳遞給set()的值在編譯期會接受檢查。下面是相關的字節碼:

public void set (java.lang.Object):
0: aload_0
1: aload_1
2: putfield #2; //Field obj:Object
5: return

public java.lang.Object get():
0: aload_0
1: getfield #2; //Field obj:Object
4: areturn

public static void main(java.lang.String[]):
0: new #3; //class GenericHolder
3: dup
4: invokespecial #4; //Method "<init>:()v
7: a-store_1
8: aload_ 1
9: ldc #5; //String Item;
11: invokevitrual #6; //Method set:(Object;)V
14: aload_1
15: invokevirtual #7: //Method get:()Object;
18: checkcast #8; //class java/lang/String
21: astore_2
22: return

所產生的字節碼是相同的。對進入set()的類型進行檢查是不需要的,因爲這將由編譯器執行。而對從get()返回的值進行轉型仍舊是需要的,但這與你自己必須執行的操作是一樣的——此處它將由編譯器自動插入,因此你寫人(和讀取)的代碼的噪聲將更小,

由於所產生的get()和set()的字節碼相同,所以在泛型中的所有動作都發生在邊界處——對傳遞進來的值進行額外的編譯期檢查,並插入對傳遞出去的值的轉型。這有助於澄清對擦除的混淆,記住,“邊界就是發生動作的地方”。

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