Java泛型簡明教程

vaikan發表於2015-02-06

泛型是Java SE 5.0中引入的一項特徵,自從這項語言特徵出現多年來,我相信,幾乎所有的Java程式設計師不僅聽說過,而且使用過它。關於Java泛型的教程,免費的,不免費的,有很多。我遇到的最好的教材有:

儘管有這麼多豐富的資料,有時我感覺,有很多的程式設計師仍然不太明白Java泛型的功用和意義。這就是為什麼我想使用一種最簡單的形式來總結一下程式設計師需要知道的關於Java泛型的最基本的知識。

Java泛型由來的動機

理解Java泛型最簡單的方法是把它看成一種便捷語法,能節省你某些Java型別轉換(casting)上的操作:
List box = …;
Apple apple = box.get(0);

上面的程式碼自身已表達的很清楚:box是一個裝有Apple物件的List。get方法返回一個Apple物件例項,這個過程不需要進行型別轉換。沒有泛型,上面的程式碼需要寫成這樣:

List box = …;
Apple apple = (Apple) box.get(0);

很明顯,泛型的主要好處就是讓編譯器保留引數的型別資訊,執行型別檢查,執行型別轉換操作:編譯器保證了這些型別轉換的絕對無誤。

相對於依賴程式設計師來記住物件型別、執行型別轉換——這會導致程式執行時的失敗,很難除錯和解決,而編譯器能夠幫助程式設計師在編譯時強制進行大量的型別檢查,發現其中的錯誤。

泛型的構成

由泛型的構成引出了一個型別變數的概念。根據Java語言規範,型別變數是一種沒有限制的標誌符,產生於以下幾種情況:

  • 泛型類宣告
  • 泛型介面宣告
  • 泛型方法宣告
  • 泛型構造器(constructor)宣告

泛型類和介面

如果一個類或介面上有一個或多個型別變數,那它就是泛型。型別變數由尖括號界定,放在類或介面名的後面:

 

 

簡單的說,型別變數扮演的角色就如同一個引數,它提供給編譯器用來型別檢查的資訊。

Java類庫裡的很多類,例如整個Collection框架都做了泛型化的修改。例如,我們在上面的第一段程式碼裡用到的List介面就是一個泛型類。在那段程式碼裡,box是一個List物件,它是一個帶有一個Apple型別變數的List介面的類實現的例項。編譯器使用這個型別變數引數在get方法被呼叫、返回一個Apple物件時自動對其進行型別轉換。

實際上,這新出現的泛型標記,或者說這個List介面裡的get方法是這樣的:

T get(int index);

get方法實際返回的是一個型別為T的物件,T是在List宣告中的型別變數。

泛型方法和構造器(Constructor)

非常的相似,如果方法和構造器上宣告瞭一個或多個型別變數,它們也可以泛型化。

public staticT getFirst(Listlist)

這個方法將會接受一個List型別的引數,返回一個T型別的物件。

例子

你既可以使用Java類庫裡提供的泛型類,也可以使用自己的泛型類。

型別安全的寫入資料…

下面的這段程式碼是個例子,我們建立了一個List例項,然後裝入一些資料:

 

 

如果我們試圖在List裝入另外一種物件,編譯器就會提示錯誤:

型別安全的讀取資料…

當我們在使用List物件時,它總能保證我們得到的是一個String物件:

遍歷

類庫中的很多類,諸如Iterator,功能都有所增強,被泛型化。List介面裡的 iterator()方法現在返回的是Iterator,由它的T next()方法返回的物件不需要再進行型別轉換,你直接得到正確的型別。

 

 

使用foreach

“for each”語法同樣受益於泛型。前面的程式碼可以寫出這樣:

 

 

這樣既容易閱讀也容易維護。

自動封裝(Autoboxing)和自動拆封(Autounboxing)

在使用Java泛型時,autoboxing/autounboxing這兩個特徵會被自動的用到,就像下面的這段程式碼:

 

 

然而,你要明白的一點是,封裝和解封會帶來效能上的損失,所有,通用要謹慎的使用。

子型別

在Java中,跟其它具有物件導向型別的語言一樣,型別的層級可以被設計成這樣:

  在Java中,型別T的子型別既可以是型別T的一個擴充套件,也可以是型別T的一個直接或非直接實現(如果T是一個介面的話)。因為“成為某型別的子型別”是一個具有傳遞性質的關係,如果型別A是B的一個子型別,B是C的子型別,那麼A也是C的子型別。在上面的圖中:

  • FujiApple(富士蘋果)是Apple的子型別
  • Apple是Fruit(水果)的子型別
  • FujiApple(富士蘋果)是Fruit(水果)的子型別

所有Java型別都是Object型別的子型別。

B型別的任何一個子型別A都可以被賦給一個型別B的宣告:

泛型型別的子型別

如果一個Apple物件的例項可以被賦給一個Fruit物件的宣告,就像上面看到的,那麼,List和 a List之間又是個什麼關係呢?更通用些,如果型別A是型別B的子型別,那C 和 C之間是什麼關係?

答案會出乎你的意料:沒有任何關係。用更通俗的話,泛型型別跟其是否子型別沒有任何關係。

這意味著下面的這段程式碼是無效的:

下面的同樣也不允許:

為什麼?一個蘋果是一個水果,為什麼一箱蘋果不能是一箱水果?

在某些事情上,這種說法可以成立,但在型別(類)封裝的狀態和操作上不成立。如果把一箱蘋果當成一箱水果會發生什麼情況?

如果可以這樣的話,我們就可以在list裡裝入各種不同的水果子型別,這是絕對不允許的。

另外一種方式會讓你有更直觀的理解:一箱水果不是一箱蘋果,因為它有可能是一箱另外一種水果,比如草莓(子型別)。

這是一個需要注意的問題嗎?

應該不是個大問題。而程式設計師對此感到意外的最大原因是陣列和泛型型別上用法的不一致。對於泛型型別,它們和型別的子型別之間是沒什麼關係的。而對於陣列,它們和子型別是相關的:如果型別A是型別B的子型別,那麼A[]是B[]的子型別:

可是稍等一下!如果我們把前面的那個議論中暴露出的問題放在這裡,我們仍然能夠在一個apple型別的陣列中加入strawberrie(草莓)物件:

這樣寫真的可以編譯,但是在執行時丟擲ArrayStoreException異常。因為陣列的這特點,在儲存資料的操作上,Java執行時需要檢查型別的相容性。這種檢查,很顯然,會帶來一定的效能問題,你需要明白這一點。

重申一下,泛型使用起來更安全,能“糾正”Java陣列中這種型別上的缺陷。

現在估計你會感到很奇怪,為什麼在陣列上會有這種型別和子型別的關係,我來給你一個《Java Generics and Collections》這本書上給出的答案:如果它們不相關,你就沒有辦法把一個未知型別的物件陣列傳入一個方法裡(不經過每次都封裝成Object[]),就像下面的:
void sort(Object[] o);

泛型出現後,陣列的這個個性已經不再有使用上的必要了(下面一部分我們會談到這個),實際上是應該避免使用。

萬用字元

在本文的前面的部分裡已經說過了泛型型別的子型別的不相關性。但有些時候,我們希望能夠像使用普通型別那樣使用泛型型別:

  • 向上造型一個泛型物件的引用
  • 向下造型一個泛型物件的引用

向上造型一個泛型物件的引用

例如,假設我們有很多箱子,每個箱子裡都裝有不同的水果,我們需要找到一種方法能夠通用的處理任何一箱水果。更通俗的說法,A是B的子型別,我們需要找到一種方法能夠將C型別的例項賦給一個C型別的宣告。

為了完成這種操作,我們需要使用帶有萬用字元的擴充套件宣告,就像下面的例子裡那樣:
List apples = new ArrayList();
List fruits = apples;

“? extends”是泛型型別的子型別相關性成為現實:Apple是Fruit的子型別,List是 List的子型別。

向下造型一個泛型物件的引用

現在我來介紹另外一種萬用字元:? super。如果型別B是型別A的超型別(父型別),那麼C 是 C 的子型別:

為什麼使用萬用字元標記能行得通?

原理現在已經很明白:我們如何利用這種新的語法結構?

? extends

讓我們重新看看這第二部分使用的一個例子,其中談到了Java陣列的子型別相關性:

就像我們看到的,當你往一個宣告為Fruit陣列的Apple物件陣列裡加入Strawberry物件後,程式碼可以編譯,但在執行時丟擲異常。

現在我們可以使用萬用字元把相關的程式碼轉換成泛型:因為Apple是Fruit的一個子類,我們使用? extends 萬用字元,這樣就能將一個List物件的定義賦到一個List的宣告上:

這次,程式碼就編譯不過去了!Java編譯器會阻止你往一個Fruit list里加入strawberry。在編譯時我們就能檢測到錯誤,在執行時就不需要進行檢查來確保往列表里加入不相容的型別了。即使你往list里加入Fruit物件也不行:

fruits.add(new Fruit());

你沒有辦法做到這些。事實上你不能夠往一個使用了? extends的資料結構裡寫入任何的值。

原因非常的簡單,你可以這樣想:這個? extends T 萬用字元告訴編譯器我們在處理一個型別T的子型別,但我們不知道這個子型別究竟是什麼。因為沒法確定,為了保證型別安全,我們就不允許往裡面加入任何這種型別的資料。另一方面,因為我們知道,不論它是什麼型別,它總是型別T的子型別,當我們在讀取資料時,能確保得到的資料是一個T型別的例項:
Fruit get = fruits.get(0);

? super

使用 ? super 萬用字元一般是什麼情況?讓我們先看看這個:
List fruits = new ArrayList();
List = fruits;

我們看到fruits指向的是一個裝有Apple的某種超類(supertype)的List。同樣的,我們不知道究竟是什麼超類,但我們知道 Apple和任何Apple的子類都跟它的型別相容。既然這個未知的型別即是Apple,也是GreenApple的超類,我們就可以寫入:
fruits.add(new Apple());
fruits.add(new GreenApple());

如果我們想往裡面加入Apple的超類,編譯器就會警告你:
fruits.add(new Fruit());
fruits.add(new Object());

因為我們不知道它是怎樣的超類,所有這樣的例項就不允許加入。

從這種形式的型別裡獲取資料又是怎麼樣的呢?結果表明,你只能取出Object例項:因為我們不知道超類究竟是什麼,編譯器唯一能保證的只是它是個Object,因為Object是任何Java型別的超類。

存取原則和PECS法則

總結 ? extends 和 the ? super 萬用字元的特徵,我們可以得出以下結論:

  • 如果你想從一個資料型別裡獲取資料,使用 ? extends 萬用字元
  • 如果你想把物件寫入一個資料結構裡,使用 ? super 萬用字元
  • 如果你既想存,又想取,那就別用萬用字元。

這就是Maurice Naftalin在他的《Java Generics and Collections》這本書中所說的存取原則,以及Joshua Bloch在他的《Effective Java中文版(第2版)》這本書中所說的PECS法則。

Bloch提醒說,這PECS是指”Producer Extends, Consumer Super”,這個更容易記憶和運用。

原文:Java code geek
譯文:外刊IT評論

相關文章