Java編程思想(三)第15章-泛型

目錄:

Java編程思想(一)第1~13、16章
Java編程思想(二)第14章-類型信息
Java編程思想(三)第15章-泛型
Java編程思想(四)第17章-容器深入研究
Java編程思想(五)第18章-Java IO系統
Java編程思想(六)第19章-枚舉類型
Java編程思想(七)第20章-註解
Java編程思想(八)第21章-併發

泛型(generics)的概念是Java SE5的重大變化之一。泛型實現了參數化類型(parameterized types)的概念,使代碼可以應用於多種類型。“泛型”這個術語的意思是:“適用於許多許多的類型”。

1 泛型方法

  泛型方法與其所在的類是否是泛型沒在關係,即泛型方法所在的類以是泛型類也可以不是泛型類。

  • 泛型方法使得該方法能夠獨立於類而產生變化 。
  • 一個基本指導原則:無論何時,只要你能做到,你就應該儘量使用泛型方法。也就是說如果使用泛型方法可以取代將整個類泛型化,那麼就應該只使用泛型方法,因爲它可以使事情更清楚明白。
  • 對於一個static方法而言,無法訪問泛型類的類型參數,所以,如果static方法需要使用泛型能力,就必須使其成爲泛型方法。
  • 要定義泛型方法,只需將泛型參數列表置於返回值之前。

1.1 類型參數推斷

  使用泛型方法的時候,通常不必指明參數類型,因爲編譯器會爲我們找出具體的類型。這稱爲類型參數推斷(type argument inference)。

  • 類型推斷只對賦值操作有效
  • 如果將一個泛型方法調用的結果作爲參數,傳遞給另一個方法,這時編譯器並不會執行類型推斷

1.1.1 顯式的類型說明

  在點操作符與方法名之間插入尖括號,然後把類型置於尖括號內,即顯式的類型說明

2 擦除的神祕之處

根據JDK文檔的描述,Class.getTypeParameters()將“返回一個TypeVariable對象數組,表示有泛型聲明的類型參數…..”,這好像是在暗示你可能發現參數類型的信息,但是,正如你從輸出中看到,你能夠發現的只是用作參數佔位符標識符,這並非有用的信息。

因此,殘酷的現實是:在泛型代碼內部,無法獲得任何有關泛型參數類型的信息

因此,你可以知道諸如泛型參數標識符泛型類型邊界這類信息——你卻無法知道創建某個特定實例的實際的類型參數。……,在使用Java泛型工作時它是必須處理的最基本的問題

Java泛型是使用擦除來實現的,這意味着當你在使用泛型時,任何具體的類型信息都被擦除了,你唯一知道的就是你在使用一個對象。因此 List<String>List<Integer> 在運行時事實上是相同的類型。這兩種形式都被擦除成它們的“原生類型,即 List

2.1 C++的方式

  2.1.1 以下C++模板示例:

它怎麼知道f()方法是爲類型參數T而存在的呢?當你實例化這個模板時,C++編譯器將進行檢查,因此在Manipulator<HasF>實例化的這一刻,它看到HasF擁有一個方法f()。如果情況並非如此,就會得到一個編譯期錯誤,這樣類型安全就得到了保障

// Templates.cpp
#include <iostream>
using namespace std;

template<class T> class Manipulator{
    T obj;
public:
    Manipulator(T x) { obj = x; }
    void manipulate() { obj.f(); }
};

class HasF{
public:
    void f() { cout << "HasF::f()" << endl; }
};

int main(){
    HasF hf;
    Manipulator<HasF> manipulator(hf);
    manipulator.manipulate();
}
  2.1.2 翻譯成Java,將不能編譯。

由於有了擦除,Java編譯器無法將manipulate()必須能夠在obj上調用f()這一需求映射到HasF擁有f()這一事實上。

2.2 泛型邊界

爲了調用f(),我們必須協助泛型類,給定泛型類的邊界,以此告知編譯器只能接受遵循這個邊界的類型。由於有了邊界,下面的代碼就可以編譯了。

package net.mrliuli.generics.erase;

/**
 * Created by li.liu on 2017/12/7.
 */

/**
 * 由於有了擦除,Java編譯器無法將manipulate()必須能夠在obj上調用f()這一需求映射到HasF擁有f()這一事實上。
 * @param <T>
 */
class Manipulator<T>{
    private T obj;
    public Manipulator(T x){ obj = x; }

    // Error: Cannot resolve method 'f()'
    //public void manipulate(){ obj.f(); }
}

/**
 * 爲了調用f(),我們必須協助泛型類,給定泛型類的邊界,以此告知編譯器只能接受遵循這個邊界的類型。由於有了邊界,下面的代碼就可以編譯了。
 * @param <T>
 */
class Manipulator2<T extends HasF>{
    private T obj;
    public Manipulator2(T x){ obj = x; }
    public void manipulate(){ obj.f(); }
}

public class Manipulation {
    public static void main(String[] args){
        HasF hf = new HasF();
        Manipulator<HasF> manipulator = new Manipulator<>(hf);
        //manipulator.manipulate();
        Manipulator2<HasF> manipulator2 = new Manipulator2<>(hf);
        manipulator2.manipulate();
    }
}

2.3 擦除

我們說泛型類型參數將擦除到它的第一個邊界(它可能會有多個邊界),我們還提到了類型參數的擦除。編譯器實際上會把類型參數替換爲它的擦除,就像上面的示例一樣。T 擦除到HasF,就好像在類的聲明中用 HasF 替換T 一樣。

2.4 擦除的問題

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

因此,擦除主要的正當理由是從非泛化的代碼到泛化的代碼的轉變過程,以及在不破壞現有類庫的情況下,將泛型融入Java語言。

擦除的代碼是顯著的。

  如果編寫了下面這樣的代碼:

class Foo<T>{ T var; }

  那麼看起來當你在創建Foo的實例時:

Foo<Cat> f = new Foo<Cat>();
  • class Foo中的代碼應該知道現在工作於Cat之上,而泛型語法也強烈暗示:在整個類中的各個地方,類型T都在被替換。但是事實上並非如此,無論何時,當你在編寫這個類的代碼時,必須提醒自己:“不,它只是一個Object。”
  • 擦除和遷移兼容性意味着,使用泛型並不是強制的。
class GenericBase<T>{}
class Derived1<T> extends GenericBase<T>{}
class Derived2 extends GenericBase{} // No warning

2.5 邊界處的動作

  • 即使擦除在方法或類內部移除了有關實際類型的信息,編譯器仍舊可以確保在方法或類中使用的類型的內部一致性
  • 因爲擦除在方法體中移除了類型信息,所以在運行時的問題就是邊界即對象進入和離開方法的地點。這些正是編譯器在編譯期執行類型檢查插入轉型代碼的地點。
  • 在泛型中的所有動作都發生在邊界處——對傳遞進來的值進行額外的編譯期檢查,並插入對傳遞出去的值的轉型。這有助於澄清對擦除的混淆,記住,“邊界就是發生動作的地方。”

3 擦除的補償(Compensating for erasure)

有時必須通過引入類型標籤(type tag)來對擦除進行補償(compensating)。這意味着你需要顯示地傳遞你的類型的Class對象,以便你可以在類型表達式中使用它。

  • 創建類型實例
  • 泛型數組

4 邊界(bound)

  • 邊界使得你可以在用於泛型的參數類型上設置限制條件。儘管這使得你可以強制規定泛型可以應用的類型,但是其潛在的一個更重要的效果是你可以按照自己的邊界類型來調用方法
  • 因爲擦除移除了類型信息,所以,可以用無界泛型參數調用的方法只是那些可以用Object調用的方法。
  • 但是,如果能夠將這個參數限制爲某個類型子集,那麼你就可以用這些類型子集來調用方法。
  • 通配符被限制爲單一邊界

5 通配符(wildcards)

  • 數組的一種特殊行爲
      可以將子類型的數組賦給基類型的數組引用。然後編譯期數組元素可以放置基類型及其子類型的元素,即編譯時不報錯,但運行時的數組機制知道實際的數組類型是子類,因此會在運行時檢查放置的類型是否是實際類型及其再導出的子類型,不是則拋出java.lang.ArrayStoreException異常。
  • 容器的類型與容器持有的類型
// Compile Error: incompatible types:
List<Fruit> list = new ArrayList<Apple>();  

  與數組不同,泛型沒有內建的協變類型。即*協變性對泛型不起作用

package net.mrliuli.generics.wildcards;

import java.util.*;

/**
 * Created by leon on 2017/12/8.
 */
public class GenericsAndCovariance {
    public static void main(String[] args){

        // Compile Error: incompatible types:
        //List<Fruit> list = new ArrayList<Apple>();

        // Wildcards allow covariance:
        List<? extends Fruit> flists = new ArrayList<Apple>();

        // But, 編譯器並不知道flists持有什麼類型對象。實際上上面語句使得向上轉型,丟失掉了向List中傳遞任何對象的能力,甚至是傳遞Object也不行。
        //flists.add(new Apple());
        //flists.add(new Fruit());
        //flists.add(new Object());

        flists.add(null);   // legal but uninteresting
        // We know that it returns at least Fruit:
        Fruit f = flists.get(0);
    }
}

5.1 編譯器有多聰明

  • 對於 List<? extends Fruit>set() 方法不能工作於 AppleFruit,因爲 set() 的參數也是 ? extends Furit,這意味着它可以是任何事物,而編譯器無法驗證“任何事物”的類型安全性。
  • 但是,equals() 方法工作良好,因爲它將接受Object類型而並非T類型的參數。因此,編譯器只關注傳遞進來和要返回的對象類型,它並不會分析代碼,以查看是否執行了任何實際的寫入和讀取操作。

5.2 逆變(Contravariance)

  • 使用超類型通配符。聲明通配符是由某個特定類的任何基類界定的,方法是指定<? super MyClass>,甚至或者使用類型參數:<? super T>。這使得你可以安全地傳遞一個類型對象到泛型類型中。
  • 參數apples是Apple的某種基類型的List,這樣你就知道向其中添加Apple或Apple的子類型是安全的。
package net.mrliuli.generics.wildcards;

import java.util.*;

public class SuperTypeWildcards {
    /**
     * 超類型通配符使得可以向泛型容器寫入。超類型邊界放鬆了在可以向方法傳遞的參數上所作的限制。
     * @param apples    參數apples是Apple的某種基類型的List,這樣你就知道向其中添加Apple或Apple的子類型是安全的。
     */
    static void writeTo(List<? super Apple> apples){
        apples.add(new Apple());
        apples.add(new Jonathan());
        //apples.add(new Fruit());    // Error
    }
}
  • GenericWriting.java 中 writeExact(fruitList, new Apple()); 在JDK1.7中沒有報錯,說明進入泛型方法 writeExact()T 被識別爲 Fruit,書中說報錯,可能JDK1.5將 T 識別爲 Apple
package net.mrliuli.generics.wildcards;

import java.util.*;

/**
 * Created by li.liu on 2017/12/8.
 */
public class GenericWriting {
    static <T> void writeExact(List<T> list, T item){
        list.add(item);
    }
    static List<Apple> appleList = new ArrayList<Apple>();
    static List<Fruit> fruitList = new ArrayList<Fruit>();
    static void f1(){
        writeExact(appleList, new Apple());
        writeExact(fruitList, new Apple());
    }
    static <T> void writeWithWildcard(List<? super T> list, T item){
        list.add(item);
    }
    static void f2(){
        writeWithWildcard(appleList, new Apple());
        writeWithWildcard(fruitList, new Apple());
    }
    public static void main(String[] args){
        f1();
        f2();
    }
}

5.3 無界通配符(Unbounded wildcards)

  原生泛型HolderHolder<?>

原生Holder將持有任何類型的組合,而Holder<?>將持有具有某種具體類型同構集合,因此不能只是向其中傳遞Object。

5.4 捕獲轉換

以下示例,被稱爲捕獲轉換,因爲未指定的通配符類型被捕獲,並被轉換爲確切類型。參數類型在調用f2()的過程中被捕獲,因此它可以在對f1()的調用中被使用。

package net.mrliuli.generics.wildcards;

/**
 * Created by leon on 2017/12/9.
 */
public class CaptureConversion {
    static <T> void f1(Holder<T> holder){
        T t = holder.get();
        System.out.println(t.getClass().getSimpleName());
    }
    static void f2(Holder<?> holder){
        f1(holder);     // Call with captured type
    }
    public static void main(String[] args){
        Holder raw = new Holder<Integer>(1);
        f1(raw);
        f2(raw);
        Holder rawBasic = new Holder();
        rawBasic.set(new Object());
        f2(rawBasic);
        Holder<?> wildcarded = new Holder<Double>(1.0);
        f2(wildcarded);
    }
}

6 問題

  • 基本類型不能作爲類型參數
  • 由於探險,一個類不能實現同一個泛型接口的兩種變體
  • 由於擦除,通過泛型來重載方法將產生相同的簽名,編譯出錯,不能實現重載
  • 基類劫持了接口

7 總結

我相信被稱爲泛型的通用語言特性(並非必須是其在Java中的特定實現)的目的在於可表達性,而不僅僅是爲了創建類型安全的容器。類型安全的容器是能夠創建更通用代碼這一能力所帶來的副作用。

泛型正如其名稱所暗示的:它是一種方法,通過它可以編寫出更“泛化”的代碼,這些代碼對於它們能夠作用的類型有更少的限制,因此單個的代碼段能夠應用到更多的類型上

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