1. 程式人生 > >深入理解 Java 泛型

深入理解 Java 泛型

首先提個問題:
Java 泛型的作用是什麼?泛型擦除是什麼?泛型一般用在什麼場景?

如果這個問題你答不上來,那這篇文章可能就對你有些價值。

讀完本文你將瞭解到:

什麼是泛型

泛型是Java SE 1.5 的新特性,《Java 核心技術》中對泛型的定義是:
>

“泛型” 意味著編寫的程式碼可以被不同型別的物件所重用。

可見泛型的提出是為了編寫重用性更好的程式碼。

泛型的本質是引數化型別,也就是說所操作的資料型別被指定為一個引數。
比如常見的集合類 LinkedList:

public class LinkedList<E> extends AbstractSequentialList<E> implements
    List<E>, Deque<E>, Queue<E>, Cloneable, Serializable {
//...

transient Link<E> voidLink;

//...
}

可以看到,LinkedList<E> 類名及其實現的介面名後有個特殊的部分 “”,而且它的成員的型別 Link<E> 也包含一個 “”,這個符號的就是 型別引數,它使得在執行中,建立一個 LinkedList 時可以傳入不同的型別,比如 new LinkedList,這樣它的成員存放的型別也是 String。

為什麼引入泛型

在引入泛型之前,要想實現一個通用的、可以處理不同型別的方法,你需要使用 Object 作為屬性和方法引數,比如這樣:

public class Generic {
    private Object[] mData;

    public Generic(int capacity) {
        mData = new Object[capacity];
    }

    public Object getData(int index) {
        //...
        return mData[index];
    }

    public void add(int index, Object item) {
        //...
        mData[index] = item;
    }
}

它使用一個 Object 陣列來儲存資料,這樣在使用時可以新增不同型別的物件:

    Generic generic = new Generic(10);
    generic.add(0,"shixin");
    generic.add(1, 23);

然而由於 Object 是所有類的父類,所有的類都可以作為成員被新增到上述類中;當需要使用的時候,必須進行強制轉換,而且這個強轉很有可能出現轉換異常:

    String item1 = (String) generic.getData(0);
    String item2 = (String) generic.getData(1);

上面第二行程式碼將一個 Integer 強轉成 String,執行時會報錯 :

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

at net.sxkeji.shixinandroiddemo2.test.generic.GenericTest.getData(GenericTest.java:46)

可以看到,使用 Object 來實現通用、不同型別的處理,有這麼兩個缺點:

  1. 每次使用時都需要強制轉換成想要的型別
  2. 在編譯時編譯器並不知道型別轉換是否正常,執行時才知道,不安全

根據《Java 程式設計思想》中的描述,泛型出現的動機在於:

有許多原因促成了泛型的出現,而最引人注意的一個原因,就是為了建立容器類。

事實上,在 JDK 1.5 出現泛型以後,許多集合類都使用泛型來儲存不同型別的元素,比如 Collection:

public interface Collection<E> extends Iterable<E> {

    Iterator<E> iterator();

    Object[] toArray();

    <T> T[] toArray(T[] a);
    boolean add(E e);

    boolean remove(Object o);

    boolean containsAll(Collection<?> c);

    boolean addAll(Collection<? extends E> c);

    boolean removeAll(Collection<?> c);
}   

實際上引入泛型的主要目標有以下幾點:

  • 型別安全
    • 泛型的主要目標是提高 Java 程式的型別安全
    • 編譯時期就可以檢查出因 Java 型別不正確導致的 ClassCastException 異常
    • 符合越早出錯代價越小原則
  • 消除強制型別轉換
    • 泛型的一個附帶好處是,使用時直接得到目標型別,消除許多強制型別轉換
    • 所得即所需,這使得程式碼更加可讀,並且減少了出錯機會
  • 潛在的效能收益
    • 由於泛型的實現方式,支援泛型(幾乎)不需要 JVM 或類檔案更改
    • 所有工作都在編譯器中完成
    • 編譯器生成的程式碼跟不使用泛型(和強制型別轉換)時所寫的程式碼幾乎一致,只是更能確保型別安全而已

泛型的使用方式

泛型的本質是引數化型別,也就是說所操作的資料型別被指定為一個引數。

型別引數的意義是告訴編譯器這個集合中要存放例項的型別,從而在新增其他型別時做出提示,在編譯時就為型別安全做了保證。

這種引數型別可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面、泛型方法。

/**
 * <header>
 *      Description: 泛型類
 * </header>
 * <p>
 *      Author: shixinzhang
 */
public class GenericClass<F> {
    private F mContent;

    public GenericClass(F content){
        mContent = content;
    }

    /**
     * 泛型方法
     * @return
     */
    public F getContent() {
        return mContent;
    }

    public void setContent(F content) {
        mContent = content;
    }

    /**
     * 泛型介面
     * @param <T>
     */
    public interface GenericInterface<T>{
        void doSomething(T t);
    }
}

泛型類

泛型類和普通類的區別就是類名後有型別引數列表 <E>,既然叫“列表”了,當然這裡的型別引數可以有多個,比如 public class HashMap<K, V>,引數名稱由開發者決定。

類名中宣告引數型別後,內部成員、方法就可以使用這個引數型別,比如上面的 GenericClass<F> 就是一個泛型類,它在類名後聲明瞭型別 F,它的成員、方法就可以使用 F 表示成員型別、方法引數/返回值都是 F 型別。

泛型類最常見的用途就是作為容納不同型別資料的容器類,比如 Java 集合容器類。

泛型介面

和泛型類一樣,泛型介面在介面名後新增型別引數,比如上面的 GenericInterface<T>,介面宣告型別後,介面方法就可以直接使用這個型別。

實現類在實現泛型介面時需要指明具體的引數型別,不然預設型別是 Object,這就失去了泛型介面的意義。

未指明型別的實現類,預設是 Object 型別:

public class Generic implements GenericInterface{

    @Override
    public void doSomething(Object o) {
        //...
    }
}

指明瞭型別的實現:

public class Generic implements GenericInterface<String>{
    @Override
    public void doSomething(String s) {
        //...
    }
}

泛型介面比較實用的使用場景就是用作策略模式的公共策略,比如 Java 解惑:Comparable 和 Comparator 的區別 中介紹的 Comparator,它就是一個泛型介面:

public interface Comparator<T> {

    public int compare(T lhs, T rhs);

    public boolean equals(Object object);
}

泛型介面定義基本的規則,然後作為引用傳遞給客戶端,這樣在執行時就能傳入不同的策略實現類。

泛型方法

泛型方法是指使用泛型的方法,如果它所在的類是個泛型類,那就很簡單了,直接使用類宣告的引數。

如果一個方法所在的類不是泛型類,或者他想要處理不同於泛型類宣告型別的資料,那它就需要自己宣告型別,舉個例子:

/**
 * 傳統的方法,會有 unchecked ... raw type 的警告
 * @param s1
 * @param s2
 * @return
 */
public Set union(Set s1, Set s2){
    Set result = new HashSet(s1);
    result.addAll(s2);
    return result;
}

/**
 * 泛型方法,介於方法修飾符和返回值之間的稱作 型別引數列表 <A,V,F,E...> (可以有多個)
 *      型別引數列表 指定引數、返回值中泛型引數的類型範圍,命名慣例與泛型相同
 * @param s1
 * @param s2
 * @param <E>
 * @return
 */
public <E> Set<E> union2(Set<E> s1, Set<E> s2){
    Set<E> result = new HashSet<>(s1);
    result.addAll(s2);
    return result;
}

注意上述程式碼在返回值前面也有個 <E>,它和類名後面的型別引數列表意義一致,指明瞭這個方法中型別引數的意義、範圍。

泛型的萬用字元

有時候希望傳入的型別有一個指定的範圍,從而可以進行一些特定的操作,這時候就是萬用字元邊界登場的時候了。

泛型中有三種萬用字元形式:

<?> 無限制萬用字元
<? extends E> extends 關鍵字聲明瞭型別的上界,表示引數化的型別可能是所指定的型別,或者是此型別的子類
<? super E> super 關鍵字聲明瞭型別的下界,表示引數化的型別可能是指定的型別,或者是此型別的父類

接下來介紹各個萬用字元。

無限制萬用字元 < ?>

要使用泛型,但是不確定或者不關心實際要操作的型別,可以使用無限制萬用字元(尖括號裡一個問號,即 <?> ),表示可以持有任何型別。

大部分情況下,這種限制是好的,但這使得一些理應正確的基本操作都無法完成,比如交換兩個元素的位置,看程式碼:

private void swap(List<?> list, int i, int j){
    Object o = list.get(i);
    list.set(j,o);
}

這個程式碼看上去應該是正確的,但 Java 編譯器會提示編譯錯誤,set 語句是非法的。編譯器提示我們把方法中的 List<?> 改成 List<Object> 就好了,這是為什麼呢? ?Object 不一樣嗎?

的確因為 ?Object 不一樣,List<?> 表示未知型別的列表,而 List<Object> 表示任意型別的列表。

比如傳入個 List<String> ,這時 List 的元素型別就是 String,想要往 List 裡新增一個 Object,這當然是不可以的。

藉助帶型別引數的泛型方法,這個問題可以這樣解決:

 private <E> void swapInternal(List<E> list, int i, int j) {
    //...
    list.set(i, list.set(j, list.get(i)));
}

private void swap(List<?> list, int i, int j){
    swapInternal(list, i, j);
}

swap 可以呼叫 swapInternal,而帶型別引數的 swapInternal 可以寫入。Java容器類中就有類似這樣的用法,公共的 API 是萬用字元形式,形式更簡單,但內部呼叫帶型別引數的方法。

上界萬用字元 < ? extends E>

在型別引數中使用 extends 表示這個泛型中的引數必須是 E 或者 E 的子類,這樣有兩個好處:

  • 如果傳入的型別不是 E 或者 E 的子類,編輯不成功
  • 泛型中可以使用 E 的方法,要不然還得強轉成 E 才能使用

舉個例子:

/**
 * 有限制的萬用字元之 extends (有上限),表示引數型別 必須是 BookBean 及其子類,更靈活
 * @param arg1
 * @param arg2
 * @param <E>
 * @return
 */
private <K extends ChildBookBean, E extends BookBean> E test2(K arg1, E arg2){
    E result = arg2;
    arg2.compareTo(arg1);
    //.....
    return result;
}

可以看到,型別引數列表中如果有多個型別引數上限,用逗號分開。

下界萬用字元 < ? super E>

在型別引數中使用 super 表示這個泛型中的引數必須是 E 或者 E 的父類。

根據程式碼介紹吧:

private <E> void add(List<? super E> dst, List<E> src){
    for (E e : src) {
        dst.add(e);
    }
}

可以看到,上面的 dst 型別 “大於等於” src 的型別,這裡的“大於等於”是指 dst 表示的範圍比 src 要大,因此裝得下 dst 的容器也就能裝 src。

萬用字元比較

通過上面的例子我們可以知道,無限制萬用字元 < ?> 和 Object 有些相似,用於表示無限制或者不確定範圍的場景。

兩種有限制通配形式 < ? super E> 和 < ? extends E> 也比較容易混淆,我們再來比較下。

它們的目的都是為了使方法介面更為靈活,可以接受更為廣泛的型別。

  • < ? super E> 用於靈活寫入或比較,使得物件可以寫入父型別的容器,使得父型別的比較方法可以應用於子類物件。
  • < ? extends E> 用於靈活讀取,使得方法可以讀取 E 或 E 的任意子型別的容器物件。

用《Effective Java》 中的一個短語來加深理解:

為了獲得最大限度的靈活性,要在表示 生產者或者消費者 的輸入引數上使用萬用字元,使用的規則就是:生產者有上限、消費者有下限:

PECS: producer-extends, costumer-super

因此使用萬用字元的基本原則:

  • 如果引數化型別表示一個 T 的生產者,使用 < ? extends T>;
  • 如果它表示一個 T 的消費者,就使用 < ? super T>;
  • 如果既是生產又是消費,那使用萬用字元就沒什麼意義了,因為你需要的是精確的引數型別。

小總結一下:

  • T 的生產者的意思就是結果會返回 T,這就要求返回一個具體的型別,必須有上限才夠具體;
  • T 的消費者的意思是要操作 T,這就要求操作的容器要夠大,所以容器需要是 T 的父類,即 super T;

舉個例子:

    private  <E extends Comparable<? super E>> E max(List<? extends E> e1){
        if (e1 == null){
            return null;
        }
        //迭代器返回的元素屬於 E 的某個子型別
        Iterator<? extends E> iterator = e1.iterator();
        E result = iterator.next();
        while (iterator.hasNext()){
            E next = iterator.next();
            if (next.compareTo(result) > 0){
                result = next;
            }
        }
        return result;
    }

上述程式碼中的型別引數 E 的範圍是 <E extends Comparable<? super E>>,我們可以分步檢視:

  1. 要進行比較,所以 E 需要是可比較的類,因此需要 extends Comparable<…>(注意這裡不要和繼承的 extends 搞混了,不一樣)
  2. Comparable< ? super E> 要對 E 進行比較,即 E 的消費者,所以需要用 super
  3. 而引數 List< ? extends E> 表示要操作的資料是 E 的子類的列表,指定上限,這樣容器才夠大

泛型的型別擦除

Java 中的泛型和 C++ 中的模板有一個很大的不同:

  • C++ 中模板的例項化會為每一種型別都產生一套不同的程式碼,這就是所謂的程式碼膨脹。
  • Java 中並不會產生這個問題。虛擬機器中並沒有泛型型別物件,所有的物件都是普通類。

在 Java 中,泛型是 Java 編譯器的概念,用泛型編寫的 Java 程式和普通的 Java 程式基本相同,只是多了一些引數化的型別同時少了一些型別轉換。

實際上泛型程式也是首先被轉化成一般的、不帶泛型的 Java 程式後再進行處理的,編譯器自動完成了從 Generic Java 到普通 Java 的翻譯,Java 虛擬機器執行時對泛型基本一無所知。

當編譯器對帶有泛型的java程式碼進行編譯時,它會去執行型別檢查型別推斷,然後生成普通的不帶泛型的位元組碼,這種普通的位元組碼可以被一般的 Java 虛擬機器接收並執行,這在就叫做 型別擦除(type erasure)

實際上無論你是否使用泛型,集合框架中存放物件的資料型別都是 Object,這一點不僅僅從原始碼中可以看到,通過反射也可以看到。

List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass());//true

上面程式碼輸出結果並不是預期的 false,而是 true。其原因就是泛型的擦除。

擦除的實現原理

一直有個疑問,Java 編譯器在編譯期間擦除了泛型的資訊,那執行中怎麼保證新增、取出的型別就是擦除前宣告的呢?

這篇文章瞭解到,原來泛型也只是一個語法糖,摘幾段話加深理解:

The buzzing keyword is “Type Erasure”, you guessed it right it’s the same thing we used to in our schools for erasing our mistakes in writing or drawing :).

The Same thing is done by Java compiler, when it sees code written using Generics it completely erases that code and convert it into raw type i.e. code without Generics. All type related information is removed during erasing. So your ArrayList becomes plain old ArrayList prior to JDK 1.5, formal type parameters e.g. < K, V> or < E> gets replaced by either Object or Super Class of the Type.

Also, when the translated code does not have correct type, the compiler inserts a type casting operator. This all done behind the scene so you don’t need to worry about what important to us is that Java compiler guarantees type-safety and flag any type-safety relate error during compilation.

In short Generics in Java is syntactic sugar and doesn’t store any type related information at runtime. All type related information is erased by Type Erasure, this was the main requirement while developing Generics feature in order to reuse all Java code written without Generics.

大概意思就是:

Java 編輯器會將泛型程式碼中的型別完全擦除,使其變成原始型別。

當然,這時的程式碼型別和我們想要的還有距離,接著 Java 編譯器會在這些程式碼中加入型別轉換,將原始型別轉換成想要的型別。這些操作都是編譯器後臺進行,可以保證型別安全。

總之泛型就是一個語法糖,它執行時沒有儲存任何型別資訊。

擦除導致的泛型不可變性

泛型中沒有邏輯上的父子關係,如 List 並不是 List 的父類。兩者擦除之後都是List,所以形如下面的程式碼,編譯器會報錯:

/**
 * 兩者並不是方法的過載。擦除之後都是同一方法,所以編譯不會通過。
 * 擦除之後:
 * 
 * void m(List numbers){}
 * void m(List strings){} //編譯不通過,已經存在相同方法簽名
 */
void method(List<Object> numbers) {

}

void method(List<String> strings) {

}

泛型的這種情況稱為 不可變性,與之對應的概念是 協變、逆變:

  • 協變:如果 A 是 B 的父類,並且 A 的容器(比如 List< A>) 也是 B 的容器(List< B>)的父類,則稱之為協變的(父子關係保持一致)
  • 逆變:如果 A 是 B 的父類,但是 A 的容器 是 B 的容器的子類,則稱之為逆變(放入容器就篡位了)
  • 不可變:不論 A B 有什麼關係,A 的容器和 B 的容器都沒有父子關係,稱之為不可變

Java 中陣列是協變的,泛型是不可變的。

如果想要讓某個泛型類具有協變性,就需要用到邊界。

擦除的拯救者:邊界

我們知道,泛型執行時被擦除成原始型別,這使得很多操作無法進行.

如果沒有指明邊界,型別引數將被擦除為 Object。

如果我們想要讓引數保留一個邊界,可以給引數設定一個邊界,泛型引數將會被擦除到它的第一個邊界(邊界可以有多個),這樣即使執行時擦除後也會有範圍。

比如:

public class GenericErasure {
    interface Game {
        void play();
    }
    interface Program{
        void code();
    }

    public static class People<T extends Program & Game>{
        private T mPeople;

        public People(T people){
            mPeople = people;
        }

        public void habit(){
            mPeople.code();
            mPeople.play();
        }
    }
}

上述程式碼中, People 的型別引數 T 有兩個邊界,編譯器事實上會把型別引數替換為它的第一個邊界的型別。

泛型的規則

  • 泛型的引數型別只能是類(包括自定義類),不能是簡單型別。
  • 同一種泛型可以對應多個版本(因為引數型別是不確定的),不同版本的泛型類例項是不相容的。
  • 泛型的型別引數可以有多個
  • 泛型的引數型別可以使用 extends 語句,習慣上稱為“有界型別”
  • 泛型的引數型別還可以是萬用字元型別,例如 Class

泛型的使用場景

當類中要操作的引用資料型別不確定的時候,過去使用 Object 來完成擴充套件,JDK 1.5後推薦使用泛型來完成擴充套件,同時保證安全性。

總結

1.上面說到使用 Object 來達到複用,會失去泛型在安全性和直觀表達性上的優勢,那為什麼 ArrayList 等原始碼中的還能看到使用 Object 作為型別?

根據《Effective Java》中所述,這裡涉及到一個 “移植相容性”:

泛型出現時,Java 平臺即將進入它的第二個十年,在此之前已經存在了大量沒有使用泛型的 Java 程式碼。人們認為讓這些程式碼全部保持合法,並且能夠與使用泛型的新程式碼互用,非常重要。

這樣都是為了相容,新程式碼裡要使用泛型而不是原始型別。

2.泛型是通過擦除來實現的。因此泛型只在編譯時強化它的型別資訊,而在執行時丟棄(或者擦除)它的元素型別資訊。擦除使得使用泛型的程式碼可以和沒有使用泛型的程式碼隨意互用。

3.如果型別引數在方法宣告中只出現一次,可以用萬用字元代替它。

比如下面的 swap 方法,用於交換指定 List 中的兩個位置的元素:

private <E> void swap(List<E> list, int i, int j) {
    //...
}

只出現了一次 型別引數,沒有必要宣告,完全可以用萬用字元代替:

private void swap(List<?> list, int i, int j){
    //...
}

對比一下,第二種更加簡單清晰吧。

4.陣列中不能使用泛型

這可能是 Java 泛型面試題中最簡單的一個了,當然前提是你要知道 Array 事實上並不支援泛型,這也是為什麼 Joshua Bloch 在 《Effective Java》一書中建議使用 List 來代替 Array,因為 List 可以提供編譯期的型別安全保證,而 Array 卻不能。

5.Java 中 List<Object> 和原始型別 List 之間的區別?

原始型別和帶引數型別 之間的主要區別是:

  • 在編譯時編譯器不會對原始型別進行型別安全檢查,卻會對帶引數的型別進行檢查
  • 通過使用 Object 作為型別,可以告知編譯器該方法可以接受任何型別的物件,比如String 或 Integer
  • 你可以把任何帶引數的型別傳遞給原始型別 List,但卻不能把 List< String> 傳遞給接受 List< Object> 的方法,因為泛型的不可變性,會產生編譯錯誤。

這道題的考察點在於對泛型中原始型別的正確理解。

Thanks