天天看點

Java Review(三十一、泛型)為什麼要使用泛型使用泛型深入泛型類型通配符泛型方法擦除和轉換

文章目錄

Java 集合有個缺點一一把一個對象"丢進"集合裡之後,集合就會"忘記"這個對象的資料類型 ,當再次取出該對象時 , 該對象的編譯類型就變成 了 Object 類型(其運作時類型沒變) 。

Java 集合之是以被設計成這樣,是因為集合 的 設計者不知道我們會用集合來儲存什麼類型的對象 ,是以他們把集合設計成能儲存任何類型 的對象,隻要求具有很好的通用性 。 但這樣做帶來如下兩個問題 :

  • 集合對元素類型沒有任何限制,這樣可能引發一些問題 。 例如,想建立一個隻能儲存 Dog 對象的集合,但程式也可以輕易地将 Cat 對象"丢"進去,是以可能引發異常 。
  • 由于把對象"丢進"集合時 , 集合丢失了對象的狀态資訊,集合隻知道它盛裝的是 Object,是以取出集合元素後通常還需要進行強制類型轉換 。 這種強制類型轉換既增加了程式設計的複雜度 ,也可能引發ClassCastException異常。

從 Java 5 以後, Java 引入了"參數化類型 Cparameterized type )" 的概念 ,允許程式在建立集合時指定集合元素的類型 ,如List , 這表 明 該 List 隻能儲存字元串類型的對象 。 Java 的參數化類型被稱為泛型 (Generic) 。

GenericList.java

public class GenericList
{
    public static void main(String[] args)
    {
        // 建立一個隻想儲存字元串的List集合
        List<String> strList = new ArrayList<String>();  // ①
        strList.add("瘋狂Java講義");
        strList.add("瘋狂Android講義");
        // 下面代碼将引起編譯錯誤
//      strList.add(5);    // ②
        strList.forEach(str -> System.out.println(str.length())); // ③
    }
}      

泛型,就是允許在定義類、接口、方法時使用類型形參,這個類型形參(或叫泛型)将在聲明變量、建立對象、調用方法時動态地指定(即傳入實際的類型參數,也可稱為類型實參 ) 。

下面是 Java 5 改寫後 List 接口、 Iterator 接口、 Map 的代碼片段 。

// 定義接口時指定了 一個泛型形參,該形參名為 E
public interface List<E>{
  // 在該接口裡. E 可作為類型使用
  // 下面方法可以使用 E 作為參數類型
  void add (E x);
  Iterator<E> iterator(); //①
  // 定義接口時指定了一個泛型形參 ,該形參名為 E
}
public interface Iterator<E>{
  //在該接口裡 E 完全可以作為類型使用
  E next() ;
  boolean hasNext() ;
  // 定義該接口時指定了兩個泛型形參,其形參名為 K 、 v
}  
public interface Map<K , V>{
  // 在該接口裡 K 、 V 完全可以作為類型使用
  Set<K> keySet() //②
  V put(K key, V value)
}        

尖括号中的内容一一就是泛型的實質:允許在定義接口、類時聲明泛型形參,泛型形參在整個接口、類體内可當成類型使用,幾乎所有可使用普通類型的地方都可以使用這種泛型形參 。

可以為任何類、接口增加泛型聲明(并不是隻有集合類才可以使用泛型聲明 ,雖然集合類是泛型的重要使用場所) 。 下面自定義一個 Apple 類,這個 Apple 類就可以包含一個泛型聲明 。

Apple.java

// 定義Apple類時使用了泛型聲明
public class Apple<T>
{
    // 使用T類型定義執行個體變量
    private T info;
    public Apple(){}
    // 下面方法中使用T類型來定義構造器
    public Apple(T info)
    {
        this.info = info;
    }
    public void setInfo(T info)
    {
        this.info = info;
    }
    public T getInfo()
    {
        return this.info;
    }
    public static void main(String[] args)
    {
        // 由于傳給T形參的是String,是以構造器參數隻能是String
        Apple<String> a1 = new Apple<>("蘋果");
        System.out.println(a1.getInfo());
        // 由于傳給T形參的是Double,是以構造器參數隻能是Double或double
        Apple<Double> a2 = new Apple<>(5.67);
        System.out.println(a2.getInfo());
    }
}      

當使用一個泛型類時 (包括聲明變量和建立對象兩種情況) , 都應該為這個泛型類傳入一個類型實參。如果沒有傳入類型實際參數 , 編譯器就會提出泛型警告 。 假設現在需要定義一個方法 , 該方法裡有一個集合形參,集合形參的元素類型是不确定的, 那應該怎樣定義呢?

為了表示各種泛型 List 的父類,可以使用類型通配符,類型通配符是一個問号 ( ?) ,将一個問号作為類型實參傳給 List 集合 , 寫作: List<?> (意思是元素類型未知的 List ) 。 這個問号(?)被稱為通配符,它的元素類型可以比對任何類型 。

public void test(List<?> c){
   for (int i = 0 ; i < c . size() ; i++ ){
     System . out .println (c.get(i) );
   }
}           

在使用任何類型的 List 來調用它,程式依然可以通路集合 c 中的元素,其類型是 Object,這永遠是安全的,因為不管 List 的真實類型是什麼,它包含的都是Object 。

但這種帶通配符的 List 僅表示它是各種泛型 List 的父類,并不能把元素加入到其中 。 例如,如下代碼将會引起編譯錯誤 :

List<?> c = new ArrayLi st<String> ();
//下麗程式引起編譯錯誤
c . add(new Object()) ;      

因為程式無法确定 c 集合中元素的類型,是以不能向其中添加對象 。 根據前面的 List接口定義的代碼可以發現 : add()方法有類型參數 E 作為集合的元素類型,是以傳給 add 的參數必須是 E 類的對象或者其子類的對象 。 但因為在該例中不知道 E 是什麼類型,是以程式無法将任何對象"丢進"該集合 。 唯一的例外是 nulL ——它是所有引用類型的執行個體 。

另 一方面 , 程式可以調用 get()方法來傳回 List<?>集合指定索引處的元素,其傳回值是一個未知類型,但可以肯定的是,它總是一個 Object 。 是以,把 get()的傳回值指派給一個 Object 類型的變量,或者放在任何希望是 Object 類型的地方都可以 。

當直接使用 List<?>這種形式時,即表明這個 List 集合可以是任何泛型 List 的父類 。 但還有一種特殊的情形,程式不希望這個 List<?>是任何泛型 List 的父類,隻希望它代表某一類泛型 List 的父類。

一個簡單的繪圖程式,下面先定義三個形狀類 :

Shape.java

// 定義一個抽象類Shape
public abstract class Shape
{
    public abstract void draw(Canvas c);
}      

Circle.java

// 定義Shape的子類Circle
public class Circle extends Shape
{
    // 實作畫圖方法,以列印字元串來模拟畫圖方法實作
    public void draw(Canvas c)
    {
        System.out.println("在畫布" + c + "上畫一個圓");
    }
}      

Rectangle.java

// 定義Shape的子類Rectangle
public class Rectangle extends Shape
{
    // 實作畫圖方法,以列印字元串來模拟畫圖方法實作
    public void draw(Canvas c)
    {
        System.out.println("把一個矩形畫在畫布" + c + "上");
    }
}      

上面定義了 三個形狀類,其中 Shape 是一個抽象父類 , 該抽象父類有兩個子類 : Circle 和 Rectangle 。接下來定義一個 Canvas 類 , 該畫布類可以畫數量不等的形狀 (Shape 子類的對象) 。

Canvas.java

public class Canvas
{
//  // 同時在畫布上繪制多個形狀
//  public void drawAll(List<Shape> shapes)
//  {
//      for (Shape s : shapes)
//      {
//          s.draw(this);
//      }
//  }

    // 同時在畫布上繪制多個形狀,使用被限制的泛型通配符
    public void drawAll(List<? extends Shape> shapes)
    {
        for (Shape s : shapes)
        {
            s.draw(this);
        }
    }

    public static void main(String[] args)
    {
        List<Circle> circleList = new ArrayList<Circle>();
        Canvas c = new Canvas();
        // 由于List<Circle>并不是List<Shape>的子類型,
        // 是以下面代碼引發編譯錯誤
        c.drawAll(circleList);
    }
}      

程式中使用了被限制的泛型通配符。

被限制的泛型通配符表示如下 :

//它表示泛型形參必須是 Shape 子類的 List
List<? extends Shape>      

List<? extends Shape>是受限制通配符的例子,此處的問号 (?) 代表一個未知的類型,就像前面看到的通配符一樣 。 但是此處的這個未知類型一定是 Shape 的子類型(也可以是 Shape 本身),是以可以把 Shape 稱為這個通配符的上限 (upper bound) 。

類似地,由于程式無法确定這個受限制的通配符的具體類型,是以不能把 Shape 對象或其子類的對象加入這個泛型集合中 。 例如,下面代碼就是錯誤的:

public void addRectangle(List<? extends Shape> shapes){
   //下面代碼引起編譯錯誤
    shapes .add(O , new Rectangle());
}          

簡而言之,這種指定通配符上限的集合,隻能從集合中取元素(取出的元素總是上限的類型) ,不能向集合中添加元素(因為編譯器沒法确定集合元素實際是哪種子類型) 。

除可以指定通配符的上限之外, Java 也允許指定通配符的下限,通配符的下限用<? super 類型>的方式來指定,通配符下限的作用與通配符上限的作用恰好相反 。

指定通配符的下限就是為了支援類型型變 。 比如 Foo 是 Bar 的子類,當程式需要一個 A<? super Bar>變量時,程式可以将 A<Foo> 、 A<Object>指派給 A<? super Bar>類型的變量,這種型變方式被稱為逆變。

對于逆變的泛型集合來說,編譯器隻知道集合元素是下限的父類型,但具體是哪種父類型則不确定。

是以,這種逆變的泛型集合能向其中添加元素(因為實際指派的集合元素總是逆變聲明的父類) ,從集合中取元素時隻能被當成 Object 類型處理(編譯器無法确定取出的到底是哪個父類的對象)。

假設實作一個工具方法:實作将 src 集合中的元素複制到 dest 集合的功能,因為 dest 集合可以儲存 src 集合中的所有元素,是以 dest 集合元素的類型應該是 src 集合元素類型的父類。

對于上面的 copy()方法,可以這樣了解兩個集合參數之間的依賴關系;不管 src 集合元素的類型是什麼,隻要 dest 集合元素的類型與前者相同或者是前者的父類即可,此時通配符的下限就有了用武之地 。

下面程式采用通配符下限的方式來實作該 copy()方法 :

MyUtils.java

public class MyUtils
{
    // 下面dest集合元素類型必須與src集合元素類型相同,或是其父類
    public static <T> T copy(List<? super T> dest
        , List<T> src)
    {
        T last = null;
        for (T ele  : src)
        {
            last = ele;
            // 逆變的泛型集合添加元素是安全的
            dest.add(ele);
        }
        return last;
    }
    public static void main(String[] args)
    {
        List<Number> ln = new ArrayList<>();
        List<Integer> li = new ArrayList<>();
        li.add(5);
        // 此處可準确的知道最後一個被複制的元素是Integer類型
        // 與src集合元素的類型相同
        Integer last = copy(ln , li);    // ①
        System.out.println(ln);
    }
}      

使用這種語句,就可以保證程式的①處調用後推斷出最後一個被複制的元素類型是 Integer,而不是籠統的 Number 類型 。

實際上, Java 集合架構中的 TreeSet有一個構造器也用到了這種設定通配符下限的文法,如下所示 :

//下面的 E 是定義 TreeSet 類時的泛型形參
TreeSet(Comparator<? super E> c)      

通過這種帶下限的通配符的文法 ,可以在建立 TreeSet 對象時靈活地選擇合适的 Comparator 。 假定需要建立一個 TreeSet<String>集合,并傳入一個可以比較 String 大小 的 Comparator , 這個 Comparator既可以是 Comparator,也可以是 Comparator一一隻要尖括号裡傳入的類型是 String 的父類型(或它本身) 即可。

TreeSetTest.java

public class TreeSetTest
{
    public static void main(String[] args)
    {
        // Comparator的實際類型是TreeSet的元素類型的父類,滿足要求
        TreeSet<String> ts1 = new TreeSet<>(
            new Comparator<Object>()
        {
            public int compare(Object fst, Object snd)
            {
                return hashCode() > snd.hashCode() ? 1
                    : hashCode() < snd.hashCode() ? -1 : 0;
            }
        });
        ts1.add("hello");
        ts1.add("wa");
        // Comparator的實際類型是TreeSet元素的類型,滿足要求
        TreeSet<String> ts2 = new TreeSet<>(
            new Comparator<String>()
        {
            public int compare(String first, String second)
            {
                return first.length() > second.length() ? -1
                    : first.length() < second.length() ? 1 : 0;
            }
        });
        ts2.add("hello");
        ts2.add("wa");
        System.out.println(ts1);
        System.out.println(ts2);
    }
}      

J ava 泛型不僅允許在使用通配符形參 時設定上限,而且可以在定義泛型形參 時設定 上限 ,用于表示傳給該泛型形參的實際類型要麼是該上限類型 ,要麼是該上限類型的子類。

下面程式示範了這種用法 :

public class Apple<T extends Number>
{
    T col;
    public static void main(String[] args)
    {
        Apple<Integer> ai = new Apple<>();
        Apple<Double> ad = new Apple<>();
        // 下面代碼将引起編譯異常,下面代碼試圖把String類型傳給T形參
        // 但String不是Number的子類型,是以引發編譯錯誤
//      Apple<String> as = new Apple<>();       // ①
    }
}      

上面程式定義了 一個 Apple 泛型類 , 該 Apple 類的泛型形參的上限是 Number 類,這表明使用 Apple類時為 T 形參傳入的實際類型參數隻能是 Number 或 Number 類的子類 。 上面程式在①處将引起編譯錯誤 : 類型 T 的上限是 Number 類型,而此處傳入的實際類型是 String 類型 ,既不是 Number 類型,也不

是 Number 類型的子類型,是以将會導緻編譯錯誤 。

在一種更極端的情況下,程式需要為泛型形參設定多個上限 (至多有一個父類上限,可以有多個接口上限),表明該泛型形參必須是其父類的子類(是父類本身也行),并且實作多個上限接口。

如下代碼所示 :

// 表明 T 類型必須是 Number 類或其子類,并必須實作 java.io.Seria1izab1e 接口
pub1ic c1ass Apple<T extends Number & java. i o . Serializab1e>{
   ……
}      

假設需要實作這樣一個方法一一該方法負責将一個 Object 數組的所有元素添加到一個 Collection 集合中 。

考慮采用如下代碼來實作該方法:

static void fromArrayToCollection(Object[) a , Collection<Object> c){
     for (Object 0 : a){
       c . add (o);
     }
}             

上面定義的方法沒有任何問題,關鍵在于方法中的 c 形參,它的資料類型是 Collection<Object>。 Collection不是 Collection的子類型一一是以這個方法的功能非常有限,它隻能将 Object[]數組的元素複制到元素為 Object ( Object 的子類不行)的 Collection 集合中。

下面代碼将引起編譯錯誤 :

String[] strArr = {"a" , "b " };
List<String> strList = new ArrayList<>() ;
/ / Collection<String>對象不能當成 Collection<Object>使用,下面代碼出現編譯錯誤
fromArrayToCollection(strArr , strList);      

為了解決這個問題,可以使用 Java 5 提供的泛型方法 (Generic Method)。所謂泛型方法,就是在聲明方法時定義→個或多個泛型形參。

泛型方法的文法格式如下:

修飾符 <T , S> 傳回值類型方法名(形參清單){
   //方法體 .. .
}         

采用支援泛型的方法,就可以将上面的fromArrayToCollection 方法改為如下形式:

static <T> void fromArrayToCollection (T[] a , Collection<T> c){
   for (T 0 : a){
     c.add(o) ;
   }  
}         

下面程式示範了完整的用法 :

GenericMethodTest.java

public class GenericMethodTest
{
    // 聲明一個泛型方法,該泛型方法中帶一個T泛型形參,
    static <T> void fromArrayToCollection(T[] a, Collection<T> c)
    {
        for (T o : a)
        {
            c.add(o);
        }
    }
    public static void main(String[] args)
    {
        Object[] oa = new Object[100];
        Collection<Object> co = new ArrayList<>();
        // 下面代碼中T代表Object類型
        fromArrayToCollection(oa, co);
        String[] sa = new String[100];
        Collection<String> cs = new ArrayList<>();
        // 下面代碼中T代表String類型
        fromArrayToCollection(sa, cs);
        // 下面代碼中T代表Object類型
        fromArrayToCollection(sa, co);
        Integer[] ia = new Integer[100];
        Float[] fa = new Float[100];
        Number[] na = new Number[100];
        Collection<Number> cn = new ArrayList<>();
        // 下面代碼中T代表Number類型
        fromArrayToCollection(ia, cn);
        // 下面代碼中T代表Number類型
        fromArrayToCollection(fa, cn);
        // 下面代碼中T代表Number類型
        fromArrayToCollection(na, cn);
        // 下面代碼中T代表Object類型
        fromArrayToCollection(na, co);
        // 下面代碼中T代表String類型,但na是一個Number數組,
        // 因為Number既不是String類型,
        // 也不是它的子類,是以出現編譯錯誤
//      fromArrayToCollection(na, cs);
    }
}      

大多數時候都可以使用泛型方法來代替類型通配符 。

例如,對于 Java 的 Collection 接口中兩個方法定義 :

public interface Collection<E>{
   boolean containsAll (Coll ection<?> c);
   boolean addAll(Collection<? extends E> c) ;
   ……
}         

上面集合中兩個方法的形參都采用了類型通配符的形式,也可以采用 泛型方法的形式, 如下所示 :

public interface Collection<E>{
  <T> boolean containsAll(Collection<T> c);
  <T extends E> boolean addAll(Col工ection<T> c) ;
  ……
} 
       

上面方法使用了 <T extends E>泛型形式 , 這時定義泛型形參時設定上限(其中 E 是 Collection 接口裡定義的泛型,在該接口裡 E 可當成普通類型使用) 。

上面兩個方法中泛型形參 T 隻使用了 一次,泛型形參 T 産生的唯一效果是可以在不同的調用點傳入不同的實際類型 。對于這種情況,應該使用通配符 : 通配符就是被設計用來支援靈活的子類化的 。

泛型方法允許泛型形參被用來表示方法的一個或多個參數之間的類型依賴關系,或者方法傳回值與參數之間的類型依賴關系。如果沒有這樣的類型依賴關系,就不應該使用泛型方法。

正如泛型方法允許在方法簽名中聲明泛型形參一樣, Java 也允許在構造器簽名中聲明泛型形參 ,這樣就産生了所謂的泛型構造器 。

一旦定義了泛型構造器,接下來在調用構造器時,就不僅可以讓 Java 根據資料參數的類型來"推斷"泛型形參的類型,而且程式員也可以顯式地為構造器中的泛型形參指定實際的類型 。

如下程式所示:

GenericConstructor.java

class Foo
{
    public <T> Foo(T t)
    {
        System.out.println(t);
    }
}
public class GenericConstructor
{
    public static void main(String[] args)
    {
        // 泛型構造器中的T類型為String。
        new Foo("瘋狂Java講義");
        // 泛型構造器中的T類型為Integer。
        new Foo(200);
        // 顯式指定泛型構造器中的T類型為String,
        // 傳給Foo構造器的實參也是String對象,完全正确。
        new <String> Foo("瘋狂Android講義");
        // 顯式指定泛型構造器中的T類型為String,
        // 但傳給Foo構造器的實參是Double對象,下面代碼出錯
        new <String> Foo(12.3);
    }
}      

Java 7 新增 的"菱形"文法 ,它允許調用構造器時在構造器後使用 一對尖括号來代表泛型資訊 。 但如果程式顯式指定了泛型構造器中聲明的泛型形參的實際類型,則不可以使用"菱形"文法 。

如下程式所示 :

GenericDiamondTest.java

class MyClass<E>
{
    public <T> MyClass(T t)
    {
        System.out.println("t參數的值為:" + t);
    }
}
public class GenericDiamondTest
{
    public static void main(String[] args)
    {
        // MyClass類聲明中的E形參是String類型。
        // 泛型構造器中聲明的T形參是Integer類型
        MyClass<String> mc1 = new MyClass<>(5);
        // 顯式指定泛型構造器中聲明的T形參是Integer類型,
        MyClass<String> mc2 = new <Integer> MyClass<String>(5);
        // MyClass類聲明中的E形參是String類型。
        // 如果顯式指定泛型構造器中聲明的T形參是Integer類型
        // 此時就不能使用"菱形"文法,下面代碼是錯的。
//      MyClass<String> mc3 = new <Integer> MyClass<>(5);
    }
}      

在嚴格的泛型代碼裡,帶泛型聲明的類總應該帶着類型參數 。 但為了與老的 Java 代碼保持一緻,也允許在使用帶泛型聲明的類時不指定實際的類型 。 如果沒有為這個泛型類指定實際的類型, 此時被稱作 raw type (原始類型) , 預設是聲明該泛型形參時指定的第一個上限類型。

當把一個具有泛型資訊的對象賦給另 一個沒有泛型資訊的變量時,所有在尖括号之間的類型資訊都将被扔掉 。 比如一個 List類型被轉換為 List,則該List 對集合元素的類型檢查變成了泛型參數的上限(即 Object ) 。

下面程式示範了這種擦除:

ErasureTest.java

class Apple<T extends Number>
{
    T size;
    public Apple()
    {
    }
    public Apple(T size)
    {
        this.size = size;
    }
    public void setSize(T size)
    {
        this.size = size;
    }
    public T getSize()
    {
        return this.size;
    }
}
public class ErasureTest
{
    public static void main(String[] args)
    {
        Apple<Integer> a = new Apple<>(6);    // ①
        // a的getSize方法傳回Integer對象
        Integer as = a.getSize();
        // 把a對象賦給Apple變量,丢失尖括号裡的類型資訊
        Apple b = a;      // ②
        // b隻知道size的類型是Number
        Number size1 = b.getSize();
        // 下面代碼引起編譯錯誤
//      Integer size2 = b.getSize();  // ③
    }
}
      

從邏輯上來看, List<String>是 List 的子類,如果直接把一個 List 對象賦給一個 List<String>對象應該引起編譯錯誤,但實際上不會。對泛型而言,可以直接把一個 List 對象賦給一個 List對象 ,編譯器僅僅提示"未經檢查的轉換"。

看下面程式 :

ErasureTest2.java

public class ErasureTest2
{
    public static void main(String[] args)
    {
        List<Integer> li = new ArrayList<>();
        li.add(6);
        li.add(9);
        List list = li;
        // 下面代碼引起“未經檢查的轉換”的警告,編譯、運作時完全正常
        List<String> ls = list;     // ①
        // 但隻要通路ls裡的元素,如下面代碼将引起運作時異常。
        System.out.println(ls.get(0));
    }
}      

參考:

【1】:《瘋狂Java講義》

【2】:《Java核心技術 卷一》

【3】:

廖雪峰的官方網站:泛型