Java學習日常——泛型

附上思維導圖。這篇博客主要講了如下知識點。

這裏寫圖片描述


看完了《Thinking in Java》的第十五章泛型,着實被震了一驚。看之前以爲泛型就是泛型,看完之後卻發現Java的泛型是通過編譯時的擦除在繼承和多態的基礎上實現的。

因爲擦除的緣故,Java中的泛型在並不能使用運行時的信息。又因爲本質上是繼承和多態,類型參數的範圍被限制到了邊界處。Java的泛型機制更像是泛型機制的一個子集。相比之下,C++的模版(C++中的泛型機制)就顯得強大許多,通過模版還能實現編譯時多態,用前期綁定實現多態,提高運行時效率。

雖然Java的泛型機制不如C++中的模版那樣完整,但還是有很大作用的,像提供了編譯期的類型檢查,標明類型參數也可以提高程序的表達性,還有自動地轉型。


01 泛型的目的

泛型的主要目的是爲了解耦類或方法與所用類型之間的約束,使代碼具有最廣泛的的表達能力。看下面這一個例子,在這個例子中,我們要做一個計算機。

public class Computer{

    private MechanicalHarddisk mHarddisk;   // 機械硬盤

    Computer(MechanicalHarddisk harddisk){
        mHarddisk = harddisk;
    }

    public Data readDisk(){
        return mHarddisk.read();
    }

    public void writeDisk(Data data){
        mHarddisk.write(data);
    }
}

可以看到,這個類有個很大的問題,它只能裝機械硬盤。我們的Computer類被他所用的MechanicalHarddisk類型約束起來了。如果我們以後還需要使用固態硬盤的類,我們就只能重寫一個了。而通過泛型,我們可以把所用的類型通過一個類型參數從類中抽離出來,來解耦這種約束。使用泛型實現的Computer類如下。

public class Computer<T extends Harddisk> {
    private T mHarddisk;    // 硬盤

    Computer(Harddisk harddisk){
        mHarddisk = harddisk;
    }

    public Data readDisk(){
        return mHarddisk.read();
    }

    public void writeDisk(Data data){
        mHarddisk.write(data);
    }
}

可以看到,我們把硬盤的類型抽成了一個類型參數T,其中T extends Harddisk是對類型參數T的一種約束,表示T的類型必須是Harddisk類的子類,從語義上說即T必須是個硬盤。後面講到Java泛型的實現時我們會發現這個約束對於這個例子是必加的。

自此約束就不復存在了。如果,我們需要一個使用機械硬盤的計算機類,那就是Computer<MechanicalHarddisk>,而如果需要一個使用固態硬盤的計算機類,那就是Computer<SolidstateHarddisk>,如果以後有了新品種的硬盤,那就把類型參數指定爲新品種就好了。


02 泛型的實現

Java泛型的實現從本質上來講就是繼承和多態。同樣以我們的Computer類爲例,我們也可以通過如下的方式進行解耦。

public class Computer{
    private Harddisk mHarddisk; // 硬盤

    Computer(Harddisk harddisk){
        mHarddisk = harddisk;
    }

    public Data readDisk(){
        return mHarddisk.read();
    }

    public void writeDisk(Data data){
        mHarddisk.write(data);
    }
}

因爲MechanicalHarddiskSolidstateHarddisk也是一個Harddisk,它們繼承自Harddisk,所以可以通過一個Harddisk引用來持有各種硬盤對象。而因爲多態性,通過這個Harddisk引用將訪問到所引對象的具體類型中的方法,如果對象的具體類型爲一個MechanicalHarddisk,那麼就會訪問MechanicalHarddiskread方法write方法。

爲了將所用類型從類中解耦出來,我們只要將所有用到該類型的地方用一個繼承鏈上的邊界類型代替即可,在這個例子中,邊界類型就是Harddisk

通過這種方式,我們就可以把類中所用類型放寬到邊界類型及其子類型。對於這個例子來說,解耦到這個程度可以說是“剛剛好,不多也不少”。

擦除

就像我們在上面說的那樣,Java泛型的實現方式就是將類型參數用邊界類型替換,在上面的例子中就是把THarddisk替換。這種實現方式看上去就像是把具體的類型(某種硬盤,機械的或者是固態的),擦除到了邊界類型(它們的父類Harddisk)。在Java中,所有的類型都最終繼承自一個唯一的類型,Object類型,這種繼承結構被稱爲單根繼承結構。在不指明邊界的情況下,即不用T extends xxx指明邊界時,邊界類型即爲Object類型

對泛型的處理全部集中在編譯期,在編譯時,編譯器會執行如下操作。

  • 會將泛型類的類型參數都用邊界類型替換。
  • 對於傳入對象給方法形參的指令,編譯器會執行一個類型檢查,看傳入的對象是不是類型參數所指定的類型。
  • 對於返回類型參數表示對象的指令,也會執行一個類型檢查,還會插入一個自動的向下轉型,將對象從邊界類型向下轉型到類型參數所表示的類型。

以一個容器類爲例。

public class Holder<T> {

    private T obj;

    public void set(T obj){
        this.obj = obj;
    }

    public T get(){
        return obj;
    }

    public static void main(String[] args){
        Holder<Integer> holder = new Holder<>();
        holder.set(1);
        Integer obj = holder.get();
    }       
}
  • 在編譯時,該類中的所有的T都會被替換爲邊界類型Object
  • 對於holder.set(1)這條指令,編譯器會檢查實參是不是一個Integer,雖然這裏的1int類型,但是因爲自動包裝機制的存在,他會被轉化爲一個Integer,因此能夠通過類型檢查。
  • 而對於Integer obj = holder.get()這條指令,編譯器也會進行類型檢查,並且自動插入一個Object類型Integer類型的轉型操作。

03 擦除給Java泛型帶來的問題

在上一節中,我們可以看到,通過擦除可以很容易地將泛型的一些像特性引入到繼承加多態的實現方式上去。

但是這種實現方式,其實有些問題。在編譯時,由於擦除,類型參數T被替換爲了邊界類型,這使得那些需要知道參數類型T的確切類型的操作都不能正常工作(注意是T這個類型參數的確切類型而不是所引對象的類型)。

public class Erased<T>{
    public void erase(String[] args){
        if(args instanceof T){}
        T var = new T();
        T[] array = new T[100];
    }
}

這段代碼中的三條語句在編譯時都會報錯。由於擦除,這三條語句運行的結果和我們期望的是不同的,比如我們要使用Erased<Integer>,那麼我們想要的運行方式是這樣的。

    if(args instanceof Integer){}
    Integer var = new Integer();
    Integer[] array = new Integer[100];

但實際上,如果能運行的話,結果是這樣的。

    if(args instanceof Object){}
    Object var = new Object();
    Object[] array = new Object[100];

和我們所期望的相去甚遠呀。所以爲了避免出現這種情況,編譯器並不會允許出現這樣的代碼。接下來讓我們來依次解決這些情況。

在泛型類中使用類型信息

上面提到擦除帶來的問題是參數類型T的確切類型在編譯時被擦除到邊界類型,使那些需要知道T確切類型的操作都不能正常工作。那既然少了個類型信息,那我們傳個進去不就好了嗎,我們可以把T對應的Class對象傳到泛型類裏面。之後要用到參數類型T的地方通過操作Class對象即可。如new T()可以通過class對象newInstance()來實現,但這個方法只適用於無參構造函數,如果有參數的要求,可以通過反射來實現,instanceof操作符和創建數組也可以通過反射來完成。

另一種思路是將需要T確切類型的操作從泛型類中抽離出來,做成一個接口。以new T()爲例。

class Erased<T>{
    public void init(IFactory<T> factory){
        T t = factory.create();  // 此處即爲new T()的工廠方法的實現
    }
}

interface IFactory<T>{
    T create();
}

class IntegerFactory implements IFactory<Integer>{
    public Integer create(){
        return new Integer(10);
    }
}

public class newTwithFactory{
    public static void main(String[] args){
        Erased<Integer> erased = new Erased<>();
        erased.init(new IntegerFactory());
    }
}

在這個例子中,把new T()操作抽成了一個接口IFactory<T>,對不同的類型參數,需要實現一個生產相應類型的工廠,並將它傳入到泛型類中。其實,前面說的Class對象也可以看作是一個工廠,只不過這個工廠是Java類庫中實現好的,我們只要拿來用就好了。這兩種實現方式還有一個不同,在使用工廠時,編譯器會提供編譯期類型檢查,而使用Class對象,是在運行時進行檢查的。

爲什麼Java的泛型會採用擦除的實現方式

其實Java在一開始是不支持泛型的,那個時候Java中的容器類都是以繼承加多態的形式實現的。直到Java SE,泛型才被引入到Java中。但此時爲了兼容以前的代碼並且將類庫改寫爲泛型的形式,Java的設計者才決定使用擦除。


04 受約束的類型參數

讓我們先來看一段代碼。

class Communicate {
    public static <T> void perform(T performer){
        performer.speak();
        performer.play();
    }   
}

class Dog {
    public void speak(){
        System.out.println("Dog speak!");
    }

    public void play(){
        System.out.println("Dog play!");
    }
}

public class CommunicateExample {
    public static void main(String[] args){
        Communicate.perform(new Dog());
    }   
}

Communicate類中,有一個perform()方法,在其中執行了傳入對象的speak()方法play()方法,爲了解耦這個方法和它接受的參數類型之間的約束,我們將接收參數抽成了一個類型參數,由調用者指定。在理想的情況下,它應該能爲含有speak()方法play()方法的所有類型提供服務。就像上面這個例子一樣,Dog類有這兩個方法,就應該能作爲這個方法的實參。但在Java中,這樣並不行,上面的代碼根本通不過編譯。

原因是還是因爲編譯時會執行擦除,類型參數T會被擦除到邊界類型Object,顯然,Object類中沒有speak()方法play()方法,編譯自然就通不過了。所以,我們必須要給他指定一個具有這兩個方法的邊界類型,就像下面這樣。

interface Performs {
    void speak();
    void play();
}

class Communicate {
    public static <T extends Performs> void perform(T performer){
        performer.speak();
        performer.play();
    }   
}

而要使用這個方法的類型,必須實現這一個接口(如果是邊界是一個類,那就是繼承這個類),就像下面這樣。

class Dog implements Performs {
    public void speak(){
        System.out.println("Dog speak!");
    }

    public void play(){
        System.out.println("Dog play!");
    }
}

修改後的代碼就能通過編譯了。但這明顯是對泛型的一種限制,因爲參數類型必須繼承自特定的類或者特定的接口,而不是我們希望的“有這兩個方法就行了”。

當然,我們也有對應的解決方案。

  1. 第一種,我們可以利用反射,在Communicate.perform()方法得到這個對象的Class對象,然後可以通過Class對象獲得這兩個方法的Method對象,最後執行這兩個方法即可。如果這個對象沒有這兩個方法,程序將會在運行時拋出異常,代碼相對優雅,但因爲藉助了反射,運行速度會稍微慢一點。
  2. 第二種,我們可以利用適配器模式。爲需要使用這個方法的類型再創建一個適配類型,適配類型繼承原類型,再實現方法所需接口即可,會產生額外的類型,運行速度比反射的實現方式要快。

05 奇怪的warning

在學習以下知識點之前,我們需要先學習以下什麼是原生類型。在使用上,原生類型就是不指名參數類型的泛化類型,以ArrayList類爲例。

// 泛型類型
ArrayList<Integer> intList = new ArrayList<>();
// 原生類型
ArrayList rawList = new ArrayList();

從本質上講它們其實是同一個類型,不懂的同學回到擦除那再看一遍。相當於我們前面講的計算機類和硬盤那個例子中的兩個實現,一個是泛型的實現方式,一種是不帶泛型,純多態的方式。

在使用泛型的過程中,常常會出現以下的unchecked warning

Note: Example.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

這通常出現在將原生類型轉化爲泛化類型的指令中。如:

public class WarningDemo {
    public static void main(String[] args){
        Box<Integer> bi;
        bi = createBox();
    }

    static Box createBox(){
        return new Box();
    }
}

在這種情況下,我們會得到如上的單個警告。爲了獲得詳細信息,我們可以按他說的做,用-Xlint:unchecked來編譯。得到的詳細信息如下:

WarningDemo.java:4: warning: [unchecked] unchecked conversion
found   : Box
required: Box<java.lang.Integer>
        bi = createBox();
                      ^
1 warning

這主要是因爲編譯器沒有足夠的類型信息來進行類型檢查,因爲編譯器並不知道原生類型對應的類型參數是什麼。


06 Java泛型的作用

雖然Java的泛型機制不是那麼完善,但對Java編程還是有挺大的幫助的。當然,並沒有什麼非常重大的突破,畢竟本質還是繼承加多態,並不是真正的泛型。

Java泛型所做的工作其實就是使我們在使用泛化代碼時顯式指明類型。比如,在沒有引入泛型前,我們這樣使用一個存放Integer對象ArrayList容器

ArrayList list = new ArrayList();
list.add(10);
...
list.add(2.345);    //  可以通過編譯,因爲ArrayList的邊界類型爲Object,但是發生了貓在狗列表的問題

在引入泛型後,我們這樣寫。

ArrayList<Integer> list = new ArrayList<>();
list.add(10);
...
list.add(2.345);    // 編譯時就會報錯,因爲我們要的是一個Integer的列表

在引入泛型後,提高了程序的表達性。我們不再用含糊不清的ArrayList,我們通過ArrayLIst<Integer>指明瞭他是一個存放Integer對象ArrayList。此外,爲了保證這個ArrayLIst<Integer>中確實是存放了Integer,編輯器提供了編譯期類型檢查,在傳遞實參給類型參數所代表的形參時,檢查了其是否是正確的類型。同時在方法傳出類型參數對應對象時,提供了自動轉型和參數檢查,使傳出的對象確實是指定的類型。

注意: 是在使用泛化代碼時指名類型,在寫泛化代碼時我們需要儘可能地忘記類型來使代碼能適用於更多的類型。

另外,注意不要濫用泛型,因爲在Java中,我們用泛型能完成的泛化代碼,通過多態也能寫出來,而且寫起來還比泛型代碼簡單(因爲Java泛型的各種缺陷使寫泛型代碼非常困難,但是泛型代碼用起來是很簡單的),而且可讀性更強(注意是泛化代碼的可讀性,在使用時泛型類的可讀性更高)。在寫泛型代碼前,需要想一想這個類的使用時可表達性是否值得我們去寫那麼一個複雜的泛型類。

借用網上的一句話:泛型泛型,如果型不泛的話還泛什麼型。


07 感想

其實泛型是多態中的一種,它被稱爲參數化多態。而我們平時說的多態其實是多態中的子類型多態。在C++中,對這兩種多態都有很好的支持,而且有完整的泛型編程。但在Java中說是泛型,其實還是子類型多態,這也是Java的一大缺憾吧。但換一個角度來想,Java也因此顯得更存粹更簡單了。

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