Java 泛型詳解

概念


泛型

泛型在編程語言中出現的最初的目的是希望類或方法具有最廣泛的表達能力。通過解耦類或者方法與所使用的類型之間的約束來實現。

通常一般的類和方法,只能應用於具體的類型,基本類型或者自定義的類,若要編寫應用於多種類型的代碼,這種限制會對代碼的束縛很大。在Java語言處於還未出現泛型的版本時,只能通過 Object 是所有類型的父類和類型強制轉換兩個特點的配合來實現類型泛化。下面是一個容器代碼實現部分代碼。

public class Container {
    private Object obj;

    /**
     * @return the obj
     */
    public Object getObj() {
        return obj;
    }

    /**
     * @param obj the obj to set
     */
    public void setObj(Object obj) {
        this.obj = obj;
    }
}

雖然上述容器會達到預期效果,但就我們的目的而言,它並不是最合適的解決方案。它不是類型安全的,並且要求在檢索封裝對象時使用顯式類型轉換,因此有可能引發異常。通過泛型可以很好的解決這些問題。

Java 泛型(generics)是 JDK 1.5 中引入的一個新特性,泛型的本質是參數化類型,也就是說所操作的數據類型被指定爲一個參數,在實例化時爲所使用的容器分配一個類型,這樣就可以創建一個對象來存儲所分配類型的對象。
所分配的類型將用於限制容器內使用的值,這樣就無需進行類型轉換,還可以在編譯時提供更強的類型檢查。類型參數的魅力在於讓程序具有更好的可讀性和安全性。
這種參數類型可以用在類、接口和方法的創建中,分別稱爲泛型類泛型接口泛型方法

類型推斷

官方給出的定義是:

Type inference is a Java compiler’s ability to look at each method invocation and corresponding declaration to determine the type argument (or arguments) that make the invocation applicable. The inference algorithm determines the types of the arguments and, if available, the type that the result is being assigned, or returned. Finally, the inference algorithm tries to find the most specific type that works with all of the arguments.

翻譯過來便是:類型推斷是 Java 編譯器查看每一個方法調用和相關聲明,以確定類型參數(或參數),使調用可用。推理算法確定參數類型,如果類型推斷成功,那麼方法返回的值就是那個類型的。最後,推理算法試圖找到與所有變量的最具體類型。

觀察下面的代碼:

static <T> T pick(T a1, T a2) { return a2; }
Serializable s = pick("d", new ArrayList<String>());

編譯器可以從以上代碼中推導出 pick 的兩個參數都是 Serializable 類型。

類型參數

類型參數(又稱類型變量)用作佔位符,指示在運行時爲類分配類型。根據需要,可能有一個或多個類型參數,並且可以用於整個類。根據慣例,類型參數是單個大寫字母,該字母用於指示所定義的參數類型。下面列出推薦的標準類型參數:

  • E:元素
  • K:鍵
  • N:數字
  • T:類型
  • V:值
  • S、U、V 等:多參數情況中的第 2、3、4 個類型

協變和逆變

維基給出的形式化定義如下:

在一門程序設計語言的類型系統中,一個類型規則或者類型構造器是:

  • 協變(covariant),如果它保持了這樣的序關係,該序關係是:子類型 ≦ 基類型。
  • 逆變(contravariant),該序關係是:基類型 ≦ 子類型。
  • 不變(invariant),如果上述兩種均不適用。

先看下面的類相關定義。

class Fruit {}
class Apple extends Fruit{} 
class Banana extends Fruit{}
class RedFuji extends Apple{}

協變就是符合我們正常邏輯的一種轉換關係。如蘋果是水果的一種,我們可以稱蘋果爲水果。

Fruit [] f = new Apple[10];

上述在Java中是完全可行的。可見數組是協變的。

// Compile Error
List<Fruit> f = new ArrayList<Apple>();

上述可見,泛型沒有內建的協變類型。Apple 的 List 在類型上不等價於 Fruit 的 List,即使 Apple 是一種 Fruit 類型。
泛型中利用通配符實現的協變和逆變:

// 協變
List<? extends Fruit> flist = new ArrayList<Apple>();
// 逆變
List<? super Apple> alist = new ArrayList<Fruit>();

上述協變和逆變在泛型中是完全可行的。後面會解釋爲什麼可行及編譯器會對這樣的對象進行什麼樣的限制

邊界

邊界使得我們可以在泛型的參數類型上設置限制條件,這可以讓我們按照邊界的類型來調用方法。

通配符

通配符指在泛型表達式中的問號 ‘?’。

  • < ? extends T> : 可以接收 T 類型或者 T 的子類型。規定了上界
  • < ? super T> :可以接收 T 類型或者 T 的父類型。規定了下界

無界

  • < ?> 看起來意味着 “任何類型”,可等價於使用原生類型。

< ?> 增加了可讀性,可解讀爲作者想使用泛型來編寫這段代碼,並不是想用原生類型,雖然這個時候泛型參數可以持有任何類型,只是我們不知道這個類型是什麼。

用法


類型參數廣泛應用在容器相關的類、接口和方法中。下面以幾個案例介紹下類型參數的使用。

泛型類

一個泛型類(generic class)就是具有一個或多個類型變量的類。在類名後,用尖括號(<>)括起來,並將類型變量寫在裏面,可有多個類型變量。
ArrayList 和 HashMap 的類泛型定義如下:

// ArrayList 泛型類的定義
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    ...
}

// HashMap 泛型類定義
public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
{
    ....
}

下面使用泛型實現一個鏈棧,定義了一個內部類 Node

/**
 * Created by manuu on 17-7-25.
 */
public class LinkedStack<T> {
    private class Node<U> {
        U item;
        Node<U> next;

        Node() {
            item = null;
            next = null;
        }

        Node(U item, Node<U> next) {
            this.item = item;
            this.next = next;
        }

        boolean end() {
            return item == null && next == null;
        }
    }
    // 設置一個哨兵
    private Node<T> top = new Node<T>();

    public void push(T item) {
        top = new Node<T>(item, top);
    }
    // 返回 T 類型
    public T pop() {
        if (!top.end()) {
            T tmp = top.item;
            top = top.next;
            return tmp;
        }
        return null;
    }

    public T peek() {
        if (!top.end()) {
            return top.item;
        }
        return null;
    }

    public static void main(String[] args) {
        // 設置 String 爲類型
        LinkedStack<String> stack = new LinkedStack<>();
        stack.push("A");
        stack.push("B");
        stack.push("C");
        String s;
        while ((s = stack.pop()) != null) {
            System.out.print(s);
        }
    }
}
/* Output:
CBA
*/

泛型接口

泛型接口的定義和泛型類相似。List 和 Map 接口的泛型定義如下:

// List 接口的泛型定義
public interface List<E> extends Collection<E> {
    ...
}
// map 接口的泛型定義
public interface Map<K,V> {
    ...
}

泛型方法

泛型方法的類型變量是放在修飾符後面,返回類型的前面。泛型方法可以定義在普通類中,也可以定義在泛型類中。下面是一個泛型方法的例子:

public static <T> T addAndReturn(T element, Collection<T> collection){
    collection.add(element);
    return element;
}

在調用泛型方法時候,可以顯式的設定類型,也可以讓編譯器通過類型推斷來判定

泛型限定

有時,類、接口或方法需要對類型變量加以約束。

考慮這樣的情況,需要對類型參數聲明的變量進行方法調用,如果只使用類型參數T,這意味着可以是任何一個類的對象。這個時候需要指定泛型類型,但希望控制可以指定的類型,而非不加限制。有界類型,在類型參數部分指定 extends 或 super 關鍵字,分別用上限或下限限制類型,從而限制泛型類型的邊界。
使用的時候需要注意以下事項:
1. 不管該限定是類還是接口,統一都使用關鍵字 extends。
2. 可以使用 ‘&’ 符號給出多個限定。
3. 如果限定既有接口也有類,那麼類必須只有一個,並且放在首位置。

通配符

泛型限定過程中利用通配符進行類型轉換的時候,需要注意的事項。

協變

先看下面代碼:

public class GenericsAndCovariance {
    public static void main(String [] args) {
        List<? extends Fruit> flist = new ArrayList<Apple>();
        // Compile Error : can't add any type of object
        // flist.add(new Apple());
        // flist.add(new Fruit());
        // flist.add(new Object());
        flist.add(null);// 編譯器允許,但無意義。
        Fruit f = flist.get(0);
    }
}

從上述代碼中可知,通過通配符實現了協變,雖然通配符繼承自Fruit, 並不意味着這個 List 可以持有任何類型的 Fruit,在某種意義上可以看成 flist 引用沒有指定具體類型。原來這個 List 持有 Apple 這樣的指定的類型,但是爲了向上轉型爲 flist,這個類型原來是什麼並沒那麼重要了。
< ? extends Fruit> 意味着從這個列表裏讀出一個 Fruit 是安全的,這個列表裏的所有對象至少是一個 Fruit,並且可能是從 Fruit 裏導出的某種對象。

在上述代碼指定了 ArrayList< ? extends Fruit>時,add() 的參數也變成了 “? extends Fruit”,這意味它可以是任何事物,這個時候編譯器並不知道需要的 Fruit 是哪一個子類型,因此它不會接受任何類型的 Fruit,因爲編譯器無法驗證 “任何事物”的類型安全性。

一旦執行了向上轉型,將丟失掉向其中傳遞任何對象的能力,甚至 Object 也不行。

逆變

看下面的代碼:

public class GenericsAndContravariant {
    static void writeTo(List<? super Apple> apples) {
        apples.add(new Apple());
        apples.add(new RedFuji());
        //  apples.add(new Fruit()); // Error
    }
}

從上述代碼可知,apples 是Apple的某種基類型的List,Apple 作爲下界,所以我們向裏面傳遞 Apple 及 Apple 的導出的任何對象是安全的。但是我們不能向內部添加 Fruit 類型,這是不安全的。

PECS

用法可以總結成:PECS ( Producer Extends,Consumer Super )。

假設你有一個 List 相關的容器,現在你想靈活的對此容器進行操作。
1. 如果你是想遍歷 List,並對每一項元素操作時,此時這個容器是 Producer(生產元素),應該使用 List< ? extends Thing>。
2. 如果你是想添加元素到 List,那麼此時容器是 Consumer(消費元素)List< ? super Thing>。

實現原理


先看下面的代碼:

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

由上述結果可知,ArrayList 和 ArrayList 在運行時事實上是相同的類型。這是由於這兩個泛型在編譯後都替換成了原始的類型。

實現原理:無論何時定義一個泛型類型,都自動提供了一個相應的原始的類型,原始類型就是刪去類型參數後的泛型類型名。擦除類型變量,並替換成限定類型(無則爲 Object)。
要明確一點的是,在泛型代碼內部,無法獲得任何有關泛型參數類型的信息。

類型擦除

在使用泛型時,任何具體的泛型信息都被擦除。這點上和 C++ 有很大的區別。C++ 爲每個模板的實例化產生不同的類型。
在類型擦除的過程中,若有多個邊界,此時將會擦除到它的第一個邊界。

擦除的核心動機是它使得泛化的客戶端可以用非泛化的類庫來使用,反之亦然,這經常被稱爲”遷移兼容性“。

採用擦除的原因

泛型是在 JDK1.5後才加入的,爲了實現遷移兼容性,Java 的設計者們採用了類型擦除的方案。通過允許非泛型代碼和泛型代碼共存,擦除使這個向泛型的遷移成爲了可能。然而擦除減少了泛型的泛化性。這是 Java 型實現的一種折中。

在基於擦除的實現泛型方案中,泛型類型被當作第二類類型處理,既不能在某些重要的上下文環境中使用的類型。泛型類型只有在靜態類型檢查期間才能出現,在此之後所有的泛型類型都會被擦除,替換成它們的非泛型上界。

注意事項


大多數限制都是由採用擦除方案引起的。

任何基本類型都不能作爲類型參數

這是因爲擦除後數據類型變爲了 Object,而 Object 並不能表示基本數據類型。然而 Java 提供了基本數據類型的包裝器。

泛型不能應用於運行時類型查詢操作

不能將泛型進行轉型,instanceof 操作和 new 表達式。因爲所有有關參數的類型信息都丟失了。

關於 instanceof 不能使用的解決方案可以在泛型內部設置一個類型標籤。然後動態的調用isInstance()。如下

public class ClassType<T> {
    Class<T> kind;
    ...

    public boolean isInstance(Object arg) {
        return kind.isInstance(arg);
    }
}

對於下面代碼編譯是行不通的。

T t = new T()

行不通的部分原因是因爲擦除,還有部分因爲編譯器不能驗證 T 是否具有默認的構造器。這種操作在 C++ 中很自然安全,它是在編譯期檢查的。
這個解決方式可以使用工廠模式,最便利的工廠對象就是Class對象,可以使用newInstance() 來創建這個類型的新對象。如下所示:

class ClassAsFactory<T> {
    T x;
    public ClassAsFactory(Class<T> kind) {
        try {
            x = kind.newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

這個方法,可能會導致運行期異常,針對這個可以編寫顯式的工廠來獲得編譯期檢查。

泛型數組

不能實例化泛型數組。
通常的解決方案是通過使用ArrayList,這樣可以獲得數組的行爲,以及由泛型提供的編譯期的類型安全。舉例如下:

public class ListOfGenerics<T> {
    private List<T> array = new ArrayList<T>();
    public void add(T item) { array.add(item); }
    public T get(int index) { return array.get(index); }
    ...
}

重載

下面的程序是不能編譯的:

public class UseList<W, T> {
    void f(List<W> v){}
    void f(List<T> v){}
}

由於擦除的原因,重載的方法將產生相同的類型簽名。
當被擦除的參數不能產生唯一的參數列表時,必須提供明顯區別的方法名。

多態與泛型

先看以下代碼:

class DateTest extends pair<Date> {
    public void setFisrt(Date fisrt) {
        if (fisrt.compareTo(getFisrt()) >= 0) {
            super.setFisrt(fisrt);
        }
    }
}
class pair<T> {
    private T fisrt;
    public pair(T fisrt)  {
        fisrt = fisrt;
    }
    public void setFisrt(T newValue) {
        fisrt = newValue;
    }
    public T getFisrt() {
        return fisrt;
    }
}

DateTest 繼承自泛型類 pair,並且實現了 setFisrt 方法。按照擦除的方案,所有的參數類型都被替換成原始類型了。當我們用父類對象指向子類對象,並且調用 setFisrt 方法時,這個時候會成功調用子類的此方法,完成多態的特性。這是因爲編譯器會在 DateTest 中生成一個橋方法 ( bridge method )。
通過javap -c DateTest 獲得以下代碼:

public void setFisrt(java.util.Date);
    Code:
       0: aload_1       
       1: aload_0       
       2: invokevirtual #2                  // Method getFisrt:()Ljava/lang/Object;
       5: checkcast     #3                  // class java/util/Date
       8: invokevirtual #4                  // Method java/util/Date.compareTo:(Ljava/util/Date;)I
      11: iflt          19
      14: aload_0       
      15: aload_1       
      16: invokespecial #5                  // Method pair.setFisrt:(Ljava/lang/Object;)V
      19: return        

  public void setFisrt(java.lang.Object);
    Code:
       0: aload_0       
       1: aload_1       
       2: checkcast     #3                  // class java/util/Date
       5: invokevirtual #6                  // Method setFisrt:(Ljava/util/Date;)V
       8: return        
}

從上述可知,在編譯器生成的橋方法 setFisrt(java.lang.Object) 中調用了子類中的 setFisrt 方法。
橋方法還應用在重寫方法中,當一個方法覆蓋另一個方法時候,可以指定一個更嚴格的返回類型時。( 具有協變的返回類型 )

實現泛型接口

一個類不能實現同一個泛型接口的兩種變體。由於擦除的存在,這兩種變體會變成相同的接口。

類型變量在靜態上下文無效

不能在靜態域或方法中的引用類型變量。例如下面會編譯不通過:

public class Singleton<T> {
    private static T instance; //ERROR
    public static T getInstance() // ERROR
    {
        return instance;
    }
}

由於擦除,以及靜態域和非靜態域的工作方式,導致禁止使用帶有類型變量的靜態域和方法。靜態域的成員是獨立於對象的,而類型變量須在對象聲明的時候進行綁定,所以這是不可取的。
然而這個需要和靜態泛型方法有所區分。泛型方法中的泛型指的是方法中的參數變量,而不是泛型類中的參數變量。所以靜態泛型方法是可取的。

不能拋出或捕獲泛型類的實例

既不能拋出也不能捕獲泛型類的對象。實際上,甚至泛型類擴展Throwable都是不合法的。
下面的代碼不能正常編譯:

public class Problem<T> extends Exception { ... } //ERROR

catch 子句中不能使用類型變量。

public static <T extends Throwable> void doWork(Class<T> t){  
    try {  
        ...  
    } catch(T e){ //編譯錯誤  
        ...  
    }  
}  

由於 擦除會替換爲Throwable,後面會和捕獲的子類會發生衝突,Java爲了避免這種衝突,直接禁止在 catch 中使用類型變量。

不過可以在異常規範中使用類型變量。以下的方法是合法的:

public static<T extends Throwable> void doWork(T t) throws T{  
    try{  
        ...  
    }catch(Throwable realCause){  
        t.initCause(realCause);  
        throw t;   
    }
}

參考資料


《Java 編程思想 第四版》
《深入理解 Java 虛擬機》
《Java 核心技術卷一 第九版》
https://zh.wikipedia.org/wiki/協變與逆變

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