Java 泛型詳解

低吟不作語發表於2020-11-22

本文部分摘自 On Java 8


概述

在 Java5 以前,普通的類和方法只能使用特定的型別:基本資料型別或類型別,如果編寫的程式碼需要應用於多種型別,這種嚴苛的限制對程式碼的束縛就會很大

Java5 的一個重大變化就是引入泛型,泛型實現了引數化型別,使得你編寫的元件(通常是集合)可以適用於多種型別。泛型的初衷是通過解耦類或方法與所使用的型別之間的約束,使得類或方法具備最寬泛的表達力。然而很快你就會發現,Java 中的泛型並沒有你想的那麼完美,甚至存在一些令人迷惑的實現


泛型類

促成泛型出現的最主要動機之一就是為了建立集合類,集合用於存放要使用到的物件。現有一個只能持有單個物件的類:

class Automobile {}

public class Holder1 {
    private Automobile a;
    public Holder1(Automobile a) { this.a = a; }
    Automobile get() { return a; }
}

如果沒有泛型,那麼就必須明確指定其持有的物件的型別,會導致該複用性不高,它無法持有其他型別的物件,我們當然不希望為每個型別都編寫一個新類

在 Java5 以前,為了解決這個問題,我們可以讓這個類直接持有 Object 型別的物件,這樣就可以持有多種不同型別的物件了。但通常而言,我們只會用集合儲存同一型別的物件。泛型的主要目的之一就是用來約定集合要儲存什麼型別的物件,並且通過編譯器確保規約得以滿足

所以,與其使用 Object,我們更希望先指定一個型別佔位符,稍後再決定具體使用什麼型別。由此我們需要使用型別引數,用尖括號括住,放在類名後面。然後在使用這個類時,再用實際的型別替換此型別引數

public class GenericHolder<T> {
    private T a;
    public GenericHolder() {}
    public void set(T a) { this.a = a; }
    public T get() { return a; }

    public static void main(String[] args) {
        // 在 Java7 中右邊的尖括號可以為空
        GenericHolder<Automobile> h2 = new GenericHolder<Automobile>();
        GenericHolder<Automobile> h3 = new GenericHolder<>();
        h3.set(new Automobile()); // 此處有型別校驗
        Automobile a = h3.get();  // 無需型別轉換
        //- h3.set("Not an Automobile"); // 報錯
    }
}

元組類庫

有時一個方法需要能返回多個物件,而 return語句只能返回單個物件,解決的方法就是建立一個物件,用它來打包想要返回的多個物件。元組的概念正是基於此,元組將一組物件直接打包儲存於單一物件中,可以從該物件讀取其中元素,卻不允許向其中儲存新物件(這個概念也稱資料傳輸物件或信使)

元組可以具有任意長度,元組中的物件可以是不同型別的,我們希望能為每個物件指明型別,這時泛型就派上用場了。例如下面是一個可以儲存兩個物件的元組:

public class Tuple<A, B> {
    public final A a1;
    public final B a2;
    public Tuple(A a, B b) { a1 = a; a2 = b; }
    public String rep() { return a1 + ", " + a2; }

    @Override
    public String toString() {
        return "(" + rep() + ")";
    }
}

使用 final 修飾成員變數可以保證其不被修改,如果使用者想儲存不同的元素,那麼就必須建立新的 Tuple 物件。當然也可以允許使用者重新對 a1、a2 賦值,但無疑前一種形式會更加安全

利用繼承機制可以實現長度更長的元組:

public class Tuple3<A, B, C> extends Tuple2<A, B> {
    public final C a3;
    public Tuple3(A a, B b, C c) {
        super(a, b);
        a3 = c;
    }

    @Override
    public String rep() {
        return super.rep() + ", " + a3;
    }
}

泛型方法

到目前為止,我們已經研究了引數化整個類,其實還可以引數化類中的方法。類本身是否是泛型,與它的方法是否是泛型並沒有什麼直接關係。我們應該儘可能使用泛型方法,通常將單個方法泛型化要比將整個類泛型化要更加清晰易懂

要定義泛型方法,請將泛型引數列表放置在返回值之前:

public class GenericMethods {
    public <T> void f(T x) {
        System.out.println(x.getClass().getName());
    }

    public static void main(String[] args) {
        GenericMethods gm = new GenericMethods();
        gm.f("");
        gm.f(1);
        gm.f(1.0);
        gm.f(1.0F);
        gm.f('c');
        gm.f(gm);
    }
}

使用泛型方法時,通常不需要指定引數型別,因為編譯器會找出這些型別,這稱為型別引數推斷,因此,對 f() 的呼叫看起來像普通的方法呼叫,而且像是被過載了無數次一樣


泛型擦除

當你開始深入研究泛型時,你會發現一個殘酷的現實:在泛型程式碼內部,無法獲取任何有關泛型引數型別的資訊

class Frob {}
class Fnorkle {}
class Quark<Q> {}
class Particle<POSITION, MOMENTUM> {}

public class LostInformation {

    public static void main(String[] args) {
        List<Frob> list = new ArrayList<>();
        Map<Frob, Fnorkle> map = new HashMap<>();
        Quark<Fnorkle> quark = new Quark<>();
        Particle<Long, Double> p = new Particle<>();
        System.out.println(Arrays.toString(list.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(map.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(quark.getClass().getTypeParameters()));
        System.out.println(Arrays.toString(p.getClass().getTypeParameters()));
    }
}

/* Output:
[E]
[K,V]
[Q]
[POSITION,MOMENTUM]
*/

正如上例中輸出所示,你只能看到用作引數佔位符的識別符號,這並非有用的資訊。Java 泛型是使用擦除實現的,這意味著當你在使用泛型時,任何具體的型別資訊都被擦除了,你唯一知道的就是你在使用一個物件。因此 List<String> 和 List 在執行時實際上是相同的型別,它們都被擦除成原生型別 List

再來看一個例子:

class Manipulator<T> {
    private T obj;

    Manipulator(T x) {
        obj = x;
    }

    // Error: cannot find symbol: method f():
    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();
    }
}

因為擦除,Java 編譯器無法將 manipulate() 方法能呼叫 obj 的 f() 方法這一需求對映到 HasF 具有 f() 方法這個事實上。為了呼叫 f(),我們必須協助泛型類,為泛型類給定一個邊界,以此告訴編譯器只能接受遵循這個邊界的型別。這裡重用了 extends 關鍵字。由於有了邊界,下面的程式碼就能通過編譯:

public class Manipulator2<T extends HasF> {
    private T obj;

    Manipulator2(T x) {
        obj = x;
    }

    public void manipulate() {
        obj.f();
    }
}

邊界 <T extends HasF> 宣告 T 必須是 HasF 型別或其子類。如果情況確實如此,就可以安全地在 obj 上呼叫 f() 方法。泛型型別引數會擦除到它的第一個邊界(可能有多個邊界,稍後你將看到)。我們還提到了型別引數的擦除。編譯器實際上會把型別引數替換為它的擦除,就像上面的示例,T 擦除到了 HasF,就像在類的宣告中用 HasF 替換了 T 一樣。如果我們願意,完全可以把上例的 T 替換成 HashF,效果也是一樣的,那麼泛型的意義又何在呢?

這提出了很重要的一點:泛型只有在型別引數比某個具體型別(以及其子類)更加“泛化”,程式碼能跨多個類工作時才有用。因此,使用型別引數通常比簡單的宣告類更加複雜。但是,不能因此認為使用 <T extends HasF> 形式就是有缺陷的。你必須檢視所有的程式碼,從而確定程式碼是否複雜到必須使用泛型的程度

有關泛型擦除的困惑,其實是 Java 為實現泛型的一種妥協,因為泛型並不是 Java 語言出現時就有的。擦除減少了泛型的泛化性,泛型型別只有在靜態型別檢測期間才出現,在此之後,程式中的所有泛型型別都將被擦除,替換為它們的非泛型上界。例如, List<T> 這樣的型別註解會被擦除為 List,普通的型別變數在未指定邊界的情況下會被擦除為 Object

在 Java5 以前編寫的類庫是沒有使用泛型的,而作者可能打算重新用泛型編寫,或者根本不打算這樣做。Java 設計者們既要保證舊程式碼和類檔案依然合法,還得考慮當某個類庫變為泛型時,不會破壞依賴於它的程式碼和應用。Java 設計者們最終認為泛型是唯一可行的解決方案,擦除使得向泛型的遷移成為可能,為了實現非泛型的程式碼和泛型程式碼共存,必須將某個類庫使用了泛型這樣的“證據”擦除

基於上述觀點,當你在編寫泛型程式碼時,必須時刻提醒自己,你只是看起來擁有有關引數的型別資訊而言。因為擦除,我們無法在執行時知道確切的型別,為了補償擦除帶來的弊端,我們可以為所需的型別顯示傳遞一個 Class 物件,以在型別表示式中使用它

class Building {
}

class House extends Building {
}

public class ClassTypeCapture<T> {
    Class<T> kind;

    public ClassTypeCapture(Class<T> kind) {
        this.kind = kind;
    }

    public boolean f(Object arg) {
        return kind.isInstance(arg);
    }

    public static void main(String[] args) {
        ClassTypeCapture<Building> ctt1 =
                new ClassTypeCapture<>(Building.class);
        System.out.println(ctt1.f(new Building()));
        System.out.println(ctt1.f(new House()));
        ClassTypeCapture<House> ctt2 =
                new ClassTypeCapture<>(House.class);
        System.out.println(ctt2.f(new Building()));
        System.out.println(ctt2.f(new House()));
    }
}

邊界和萬用字元

由於擦除會刪除型別資訊,因此唯一可用於無限制泛型引數的方法是那些 Object 可用的方法。邊界允許我們對泛型使用的引數型別施以型別,將引數限制為某型別的子集,那麼就可以呼叫該子集中的方法。為了應用約束,Java 泛型使用了 extends 關鍵字

class Coord {
    public int x, y, z;
}

interface Weight {
    int weight();
}

class Solid<T extends Coord & Weight> {
    T item;

    Solid(T item) {
        this.item = item;
    }

    T getItem() {
        return item;
    }

    int getX() {
        return item.x;
    }

    int getY() {
        return item.y;
    }

    int getZ() {
        return item.z;
    }

    int weight() {
        return item.weight();
    }
}

class Bounded
        extends Coord implements Weight {

    @Override
    public int weight() {
        return 0;
    }
}

public class BasicBounds {
    public static void main(String[] args) {
        Solid<Bounded> solid =
                new Solid<>(new Bounded());
        solid.getY();
        solid.weight();
    }
}

引入萬用字元可以在泛型例項化時更加靈活地控制,也可以在方法中控制方法的引數,具體語法如下:

  • ? extends T:表示 T 或 T 的子類
  • ? super T:表示 T 或 T 的父類
  • ?:表示可以是任意型別

值得注意的問題

在這裡主要闡述在使用 Java 泛型時會出現的各類問題

1. 任何基本資料型別不能作為型別引數

Java 泛型的限制之一是不能將基本型別用作型別引數。因此,不能建立 ArrayList<int> 之類的東西。 解決方法是使用基本型別的包裝器類以及自動裝箱機制。如果建立一個 ArrayList<Integer>,並將基本型別 int 應用於這個集合,那麼你將發現自動裝箱機制將自動地實現 int 到 Integer 的雙向轉換,這幾乎就像是有一個 ArrayList<int> 一樣

2. 實現引數化介面

一個類不能實現同一個泛型介面的兩種變體,由於擦除的原因,這兩個變體會成為相同的介面。下面是產生這種衝突的情況:

interface Payable<T> {}

class Employee implements Payable<Employee> {}

class Hourly extends Employee implements Payable<Hourly> {}

Hourly 不能編譯,因為擦除會將 Payable<Employe> 和 Payable<Hourly> 簡化為相同的類 Payable,這樣,上面的程式碼就意味著在重複兩次地實現相同的介面。十分有趣的是,如果從 Payable 的兩種用法中都移除掉泛型引數(就像編譯器在擦除階段所做的那樣)這段程式碼就可以編譯

3. 轉型和警告

使用帶有泛型型別引數的轉型不會有任何效果,例如:

class Storage<T> {
    
    private Object obj;

    Storage() {
        obj = new Object();
    }

    @SuppressWarnings("unchecked")
    public T pop() {
        return (T)obj;
    }
}

public class GenericCast {

    public static void main(String[] args) {
        Storage<String> storage = new Storage<>();
        System.out.println(storage.pop());
    }
}

如果沒有 @SuppressWarnings 註解,編譯器將對 pop() 產生 “unchecked cast” 警告。由於擦除的原因,編譯器無法知道這個轉型是否是安全的,並且 pop() 方法實際上並沒有執行任何轉型。 這是因為,T 被擦除到它的第一個邊界,預設情況下是 Object,因此 pop() 實際上只是將 Object 轉型為 Object

4. 過載

下面的程式是不能編譯的,因為擦除,所以過載方法產生了相同的型別簽名

public class UseList<W, T> {
    void f(List<T> v) {}
    void f(List<W> v) {}
}

相關文章