Java泛型詳解

jamesehng發表於2016-12-11

由於部落格的特殊顯示原因,尖括號用()代替

泛型概述

Java泛型(generics)是JDK 5中引入的一個新特性,允許在定義類和介面的時候使用型別引數(type parameter)。宣告的型別引數在使用時用具體的型別來替換。

優缺點

從好的方面來說,泛型的引入可以解決之前的集合類框架在使用過程中通常會出現的執行時刻型別錯誤,因為編譯器可以在編譯時刻就發現很多明顯的錯誤。而從不好的地方來說,為了保證與舊有版本的相容性,Java泛型的實現上存在著一些不夠優雅的地方。當然這也是任何有歷史的程式語言所需要承擔的歷史包袱。後續的版本更新會為早期的設計缺陷所累。

舉例

List(Object)作為形式引數,那麼如果嘗試將一個List(String)的物件作為實際引數傳進去,卻發現無法通過編譯。雖然從直覺上來說,Object是String的父類,這種型別轉換應該是合理的。但是實際上這會產生隱含的型別轉換問題,因此編譯器直接就禁止這樣的行為。

型別擦除

正確理解泛型概念的首要前提是理解型別擦除(type erasure)。

Java中的泛型基本上都是在編譯器這個層次來實現的。

在生成的Java位元組程式碼中是不包含泛型中的型別資訊的。使用泛型的時候加上的型別引數,會被編譯器在編譯的時候去掉。這個過程就稱為型別擦除。 如在程式碼中定義的List(Object)和List(String)等型別,在編譯之後都會變成List。JVM看到的只是List,而由泛型附加的型別資訊對JVM來說是不可見的。Java編譯器會在編譯時儘可能的發現可能出錯的地方,但是仍然無法避免在執行時刻出現型別轉換異常的情況。型別擦除也是Java的泛型實現方式與C++模板機制實現方式之間的重要區別。

很多泛型的奇怪特性都與這個型別擦除的存在有關

1.泛型類並沒有自己獨有的Class類物件。比如並不存在List(String).class或是List(Integer).class,而只有List.class。

2.靜態變數是被泛型類的所有例項所共享的。對於宣告為MyClass(T)的類,訪問其中的靜態變數的方法仍然是 MyClass.myStaticVar。不管是通過new MyClass(String)還是new MyClass(Integer)建立的物件,都是共享一個靜態變數。

3.泛型的型別引數不能用在Java異常處理的catch語句中。因為異常處理是由JVM在執行時刻來進行的。由於型別資訊被擦除,JVM是無法區分兩個異常型別MyException(String)和MyException(Integer)的。對於JVM來說,它們都是 MyException型別的。也就無法執行與異常對應的catch語句。

型別擦除的過程

型別擦除的基本過程也比較簡單,首先是找到用來替換型別引數的具體類。這個具體類一般是Object。如果指定了型別引數的上界的話,則使用這個上界。把程式碼中的型別引數都替換成具體的類。同時去掉出現的型別宣告,即去掉<>的內容。比如T get()方法宣告就變成了Object get();List(String)就變成了List。接下來就可能需要生成一些橋接方法(bridge method)。這是由於擦除了型別之後的類可能缺少某些必須的方法。

例項分析

瞭解了型別擦除機制之後,就會明白編譯器承擔了全部的型別檢查工作。編譯器禁止某些泛型的使用方式,正是為了確保型別的安全性。以上面提到的List(Object)和List(String)為例來具體分析:

public void inspect(List(Object) list) {    
    for (Object obj : list) {        
        System.out.println(obj);    
    }    
    list.add(1); //這個操作在當前方法的上下文是合法的。 
}
public void test() {    
    List(String) strs = new ArrayList(String)();    
    inspect(strs); //編譯錯誤 
}  
複製程式碼

這段程式碼中,inspect方法接受List(Object)作為引數,當在test方法中試圖傳入List(String)的時候,會出現編譯錯誤。假設這樣的做法是允許的,那麼在inspect方法就可以通過list.add(1)來向集合中新增一個數字。這樣在test方法看來,其宣告為List(String)的集合中卻被新增了一個Integer型別的物件。這顯然是違反型別安全的原則的,在某個時候肯定會丟擲ClassCastException。因此,編譯器禁止這樣的行為。編譯器會盡可能的檢查可能存在的型別安全問題。對於確定是違反相關原則的地方,會給出編譯錯誤。當編譯器無法判斷型別的使用是否正確的時候,會給出警告資訊。

泛型類

容器類應該算得上最具重用性的類庫之一。先來看一個沒有泛型的情況下的容器類如何定義:

public class Container {
    private String key;
    private String value;

    public Container(String k, String v) {
        key = k;
        value = v;
    }
    
    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}
複製程式碼

Container類儲存了一對key-value鍵值對,但是型別是定死的,也就說如果我想要建立一個鍵值對是String-Integer型別的,當前這個Container是做不到的,必須再自定義。那麼這明顯重用性就非常低。

當然,我可以用Object來代替String,並且在Java SE5之前,我們也只能這麼做,由於Object是所有型別的基類,所以可以直接轉型。但是這樣靈活性還是不夠,因為還是指定型別了,只不過這次指定的型別層級更高而已,有沒有可能不指定型別?有沒有可能在執行時才知道具體的型別是什麼?

所以,就出現了泛型。

public class Container(K, V) {
    private K key;
    private V value;

    public Container(K k, V v) {
        key = k;
        value = v;
    }

    public K getKey() {
        return key;
    }

    public void setKey(K key) {
        this.key = key;
    }

    public V getValue() {
        return value;
    }

    public void setValue(V value) {
        this.value = value;
    }
}
複製程式碼

在編譯期,是無法知道K和V具體是什麼型別,只有在執行時才會真正根據型別來構造和分配記憶體。可以看一下現在Container類對於不同型別的支援情況:

public class Main {

    public static void main(String[] args) {
        Container<String, String> c1 = new Container<String, String>("name", "findingsea");
        Container<String, Integer> c2 = new Container<String, Integer>("age", 24);
        Container<Double, Double> c3 = new Container<Double, Double>(1.1, 2.2);
        System.out.println(c1.getKey() + " : " + c1.getValue());
        System.out.println(c2.getKey() + " : " + c2.getValue());
        System.out.println(c3.getKey() + " : " + c3.getValue());
    }
}
複製程式碼
輸出:

name : findingsea
age : 24
1.1 : 2.2
複製程式碼

泛型介面

在泛型介面中,生成器是一個很好的理解,看如下的生成器介面定義:

public interface Generator<T> {
    public T next();
}
然後定義一個生成器類來實現這個介面:

public class FruitGenerator implements Generator<String> {

    private String[] fruits = new String[]{"Apple", "Banana", "Pear"};

    @Override
    public String next() {
        Random rand = new Random();
        return fruits[rand.nextInt(3)];
    }
}
呼叫:

public class Main {

    public static void main(String[] args) {
        FruitGenerator generator = new FruitGenerator();
        System.out.println(generator.next());
        System.out.println(generator.next());
        System.out.println(generator.next());
        System.out.println(generator.next());
    }
}
輸出:

Banana
Banana
Pear
Banana
複製程式碼

泛型方法

一個基本的原則是:無論何時,只要你能做到,你就應該儘量使用泛型方法。也就是說,如果使用泛型方法可以取代將整個類泛化,那麼應該有限採用泛型方法。下面來看一個簡單的泛型方法的定義:

public class Main {

    public static <T> void out(T t) {
        System.out.println(t);
    }

    public static void main(String[] args) {
        out("findingsea");
        out(123);
        out(11.11);
        out(true);
    }
}
複製程式碼

可以看到方法的引數徹底泛化了,這個過程涉及到編譯器的型別推導和自動打包,也就說原來需要我們自己對型別進行的判斷和處理,現在編譯器幫我們做了。這樣在定義方法的時候不必考慮以後到底需要處理哪些型別的引數,大大增加了程式設計的靈活性。

再看一個泛型方法和可變引數的例子:

public class Main {

    public static <T> void out(T... args) {
        for (T t : args) {
            System.out.println(t);
        }
    }

    public static void main(String[] args) {
        out("findingsea", 123, 11.11, true);
    }
}
複製程式碼

萬用字元與上下界

在使用泛型類的時候,既可以指定一個具體的型別,如List(String)就宣告瞭具體的型別是String;也可以用萬用字元?來表示未知型別,如List就宣告瞭List中包含的元素型別是未知的。 萬用字元所代表的其實是一組型別,但具體的型別是未知的。List所宣告的就是所有型別都是可以的。但是List並不等同於List(Object)。List(Object)實際上確定了List中包含的是Object及其子類,在使用的時候都可以通過Object來進行引用。而List則其中所包含的元素型別是不確定。其中可能包含的是String,也可能是 Integer。如果它包含了String的話,往裡面新增Integer型別的元素就是錯誤的。正因為型別未知,就不能通過new ArrayList(?)()的方法來建立一個新的ArrayList物件。因為編譯器無法知道具體的型別是什麼。但是對於 List(?)中的元素確總是可以用Object來引用的,因為雖然型別未知,但肯定是Object及其子類。考慮下面的程式碼:

public void wildcard(List(?) list) { list.add(1);//編譯錯誤 }
如上所示,試圖對一個帶萬用字元的泛型類進行操作的時候,總是會出現編譯錯誤。其原因在於萬用字元所表示的型別是未知的。

因為對於List(?)中的元素只能用Object來引用,在有些情況下不是很方便。在這些情況下,可以使用上下界來限制未知型別的範圍。 如List(? extends Number)說明List中可能包含的元素型別是Number及其子類。而List(? super Number)則說明List中包含的是Number及其父類。當引入了上界之後,在使用型別的時候就可以使用上界類中定義的方法。比如訪問 List(? extends Number)的時候,就可以使用Number類的intValue等方法。

型別系統

在Java中,大家比較熟悉的是通過繼承機制而產生的型別體系結構。比如String繼承自Object。根據Liskov替換原則,子類是可以替換父類的。當需要Object類的引用的時候,如果傳入一個String物件是沒有任何問題的。但是反過來的話,即用父類的引用替換子類引用的時候,就需要進行強制型別轉換。編譯器並不能保證執行時刻這種轉換一定是合法的。這種自動的子類替換父類的型別轉換機制,對於陣列也是適用的。 String[]可以替換Object[]。但是泛型的引入,對於這個型別系統產生了一定的影響。正如前面提到的List(String)是不能替換掉List(Object)的。

引入泛型之後的型別系統增加了兩個維度:

一個是型別引數自身的繼承體系結構,另外一個是泛型類或介面自身的繼承體系結構。第一個指的是對於 List(String)和List(Object)這樣的情況,型別引數String是繼承自Object的。而第二種指的是 List介面繼承自Collection介面。對於這個型別系統,有如下的一些規則:

相同型別引數的泛型類的關係取決於泛型類自身的繼承體系結構。

即List(String)是Collection(String) 的子型別,List(String)可以替換Collection(String)。這種情況也適用於帶有上下界的型別宣告。 當泛型類的型別宣告中使用了萬用字元的時候, 其子型別可以在兩個維度上分別展開。如對Collection(? extends Number)來說,其子型別可以在Collection這個維度上展開,即List(? extends Number)和Set(? extends Number)等;也可以在Number這個層次上展開,即Collection(Double)和 Collection(Integer)等。如此迴圈下去,ArrayList(Long)和 HashSet(Double)等也都算是Collection(? extends Number)的子型別。 如果泛型類中包含多個型別引數,則對於每個型別引數分別應用上面的規則。 理解了上面的規則之後,就可以很容易的修正例項分析中給出的程式碼了。只需要把List(Object)改成List(?)即可。List(String)是List(?)的子型別,因此傳遞引數時不會發生錯誤。

泛型的命名規範

為了更好地去理解泛型,我們也需要去理解java泛型的命名規範。為了與java關鍵字區別開來,java泛型引數只是使用一個大寫字母來定義。各種常用泛型引數的意義如下: E — Element,常用在java Collection裡,如:List(E),Iterator(E),Set(E) K,V — Key,Value,代表Map的鍵值對 N — Number,數字 T — Type,型別,如String,Integer等等 S,U,V etc. - 2nd, 3rd, 4th 型別,和T的用法一樣 參考: http://www.infoq.com/cn/articles/cf-java-generics https://segmentfault.com/a/1190000002646193 http://peiquan.blog.51cto.com/7518552/1302898

相關文章