一文詳解 Java 泛型設計

本文主要介紹泛型誕生的前世今生,特性,以及著名 PECS 原則的由來。

在日常開發中,必不可少的會使用到泛型,這個過程中經常會出現類似 “爲什麼這樣會編譯報錯?”,“爲什麼這個列表無法添加元素?” 的問題,也會出現感嘆 Java 的泛型限制太多了很難用的情況。爲了更好的使用泛型,就需要更深的瞭解它,因此本文主要介紹泛型誕生的前世今生,特性,以及著名 PECS 原則的由來。

泛型的誕生

背景

在沒有泛型之前,必須使用 Object 編寫適用於多種類型的代碼,想想就令人頭疼,並且非常的不安全。同時由於數組的存在,設計者爲了讓其可以比較通用的進行處理,也讓數組允許協變,這又爲程序添加了一些天然的不安全因素。爲了解決這些情況,Java 的設計者終於在 Java5 中引入泛型,然而,正是因爲引入泛型的時機較晚,爲了兼容先前的代碼,設計者也不得不做出一些限制,來讓使用者(也就是我們)以難受換來一些安全。

優點

簡單來說,泛型的引入有以下好處:

以 ArrayList 舉例,在增加泛型類之前,其通用性是用繼承來實現的,ArrayList 類只維護一個 Object 引用的數組,當我們使用這個工具類時,想要獲取指定類型的對象必須經過強轉:

import java.util.ArrayList;
import java.util.Date;
public class Main {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        //強制類型轉換
        String res = (String) list.get(0);
        //十分不安全的行爲
        list.add(new Date());
    }
}

這種寫法在編譯類型時不會報錯,但一旦使用 get 獲取結果並試圖將 Date 轉換爲其他類型時,很有可能出現類型轉換異常,爲了解決這種情況,類型參數應用而生。

類型參數

類型參數(Type parameter)使得 ArrayList 以及其他可能用到的集合類能夠方便的指示虛擬機其包含元素的類型:

import java.util.ArrayList;
public class Main {
    public static void main(String[] args) {
        ArrayList<String> objects = new ArrayList<>();
        objects.add("Hello");
    }
}

這使得代碼具有更好的可讀性,並且在調用 get() 的時候,無需進行強轉,最重要的是,編譯器終於可以檢查一個插入操作是否符合要求,運行時可能出現的各種類型轉換錯誤得以在編譯階段就被阻止。

import java.util.ArrayList;
import java.util.Date;
public class Main {
    public static void main(String[] args) {
        ArrayList<String> objects = new ArrayList<>();
        //we can do it like that
        objects.add("Hello");
        //wrong example
        objects.add(new Date());
    }
}

基本用法

一般來說,使用泛型工具類很容易,但是自己編寫會相對困難很多,設計者必須考慮的相當周全才能使自己的泛型類庫比較完善。

泛型類

泛型類是有一個或者多個類型變量的類,泛型類中的屬性可以全都不是泛型,不過一般不會這樣做,畢竟類型變量在整個類上定義就是用於指定方法的返回類型以及字段的類型,定義代碼如下:

public class Animal<T> {
    private String name;
    private T mouth;
    public T getMouth(){
        return mouth;
    }
}

泛型類可以有多個類型變量:

public class Animal<T,U> {
    private String name;
    private T mouth;
    private U eyes;
    public T getMouth(){
        return mouth;
    }
}

泛型方法

泛型方法可以在普通類中定義,也可以在泛型類中定義,例如:

public class Animal<T,U> {
    private T value;
    public static <T> T get(T... a){
        return a[a.length-1];
    }
    public T getFirst(){
        return value;
    }
}

類型擦除

虛擬機沒有泛型類型對象,也就是說,所有對象在虛擬機中都屬於普通類,這意味着在程序編譯並運行後我們的類型變量會被擦除(erased)並替換爲限定類型,擦掉類型參數後的類型就叫做原始類型(raw type),正是因爲有類型參數,所以下面的比較結果會爲 true:



這裏的替換規則我個人理解爲:“替換最近上界”,也就是無限定符修飾,則爲頂級父類 Object,如果有,則會替換爲其指定的類型。最直觀的示例如下,這就是類型擦除的體現:





前面說過,泛型是在 1.5 才提出的,因此類型擦除的目的就是爲了保證已有的代碼和類文件依然合法,也就是向低版本兼容。這樣做會帶來幾個問題:

  1. 類型參數不支持基本類型,只支持引用類型,這是因爲泛型會被擦除爲具體類型,而 Object 不能存儲基本類型的值。

運行時你只能對原始類型進行類型檢測:



  1. 不能實例化類型參數

不能實例化泛型數組,因爲類型擦除會將數組變爲 Object 數組,如果允許實例化,極易造成類型轉換異常。

強制轉換

在編寫泛型方法調用時,如果擦出了返回類型,編譯器會插入強制類型轉換。例如下面的代碼:

public class Main {
    public static void main(String[] args) {
        Animal<Integer,Double> pair = new Animal<>();
        Integer first = pair.getFirst();
    }
}

getFirst 擦除類型後的返回類型是 Object,編譯器自動插入轉換到 Integer 的強制類型轉換,也就是說,編譯器把這個方法調用轉換爲兩條虛擬機指令:

方法橋接

子類重寫父類方法時,必須和父類保持相同的方法名稱,參數列表和返回類型。那麼問題來了,如果按照之前的思路來講,當泛型父類或接口的類型參數被擦除了,那麼子類豈不是不構成重寫條件?(參數類型很可能變化):

擦除前:



擦除後:



爲了解決這個事情,Java 引入了橋接方法,爲每個繼承 / 實現泛型類 / 接口的子類服務,以此保持多態性,字節碼如下:



(圖片來源:RudeCrab)

其實現原理,就是重寫擦除後的父類方法,並在其內部委託了原始的子類方法,巧妙繞過了擦除帶來的影響。不僅如此,就算不是泛型類,當子類方法重寫父類方法的返回類型是父類返回類型的子類時,編譯器也會生成橋接方法來滿足重寫的規則。

總結

Java 核心技術中總結的非常到位:

變型(Variant)與數組

變型是類型系統中很重要的概念,主要有三個規則協變,逆變,和不變:



這三個類型可以解釋爲:假設有一個類型構造器 f,它可以將已知類型轉換爲另一種類型,那麼,有 Animal 父類和 Dog 子類。

而這個 f(),可以是泛型,可以是數組,也可以是方法。

知道了以上概念,我們需要直接指出,泛型默認是不支持協變的,原因很簡單,類型安全:如果允許協變,可能會造成類型轉換異常。而數組支持協變,正如文章開頭所說,就是設計者希望可以對數組進行比較通用的處理,防止方法爲每一種類型編寫重複邏輯,這樣做也確實導致爲數組賦值元素時可能會拋出運行時異常 ArrayStoreException,這是一個很危險的坑。Effective Java 中直接指出允許數組協變是 Java 的缺陷,我想這也是要多用列表而不用數組的原因之一。

泛型協變—PECS 原則

爲了讓泛型也支持多態,讓其支持協變是很必要的,最常用的場景:我們想讓一個方法接受一個集合,並做統一的邏輯處理,如果泛型不支持協變,這種很基本的需求都會成爲奢望。

上界

讓泛型支持協變很簡單,只需要使用? extends 的組合即可實現,? 稱爲通配符,這種組合方式聲明瞭類型的上界,標識泛型可接受的類型只能是指定類型或是其子類。在這裏,ElectricVehicle 和 Diesel 均是繼承自 Car。



爲了杜絕可協變後出現類似於數組一樣的安全隱患,泛型設計採用了 “一刀切” 的方式,即:只要聲明瞭上界,除了 null 之外,一律不準傳入給泛型。說白了,就是隻讀不寫,這樣當然可以保證安全性。



到這裏可以順便說一下集合的設計,可以注意到集合中只有 add 方法是泛型參數,而其餘方法並不是,爲何要這樣設計,爲何不把其餘方法的參數類型也改爲 E?其原因就是在於,如果將 contains 和 remove 改爲 E,那麼聲明上界之後,調用這兩個方法會引發編譯錯誤,然而這兩個方法均爲類型安全方法,自然不可聲明爲 E,add 作爲很明顯的寫方法,自然也需要用 E 作爲參數類型,到這裏,不得不感嘆類庫設計者的想法獨到。



下界

對應協變的上界,自然有逆變的下界,很自然的,我們使用? super 的組合來聲明一個泛型的下界,來表示可以接收本類型或者其父類型。



而且相對應的,正是由於最多隻能接收父類型泛型,所以不會有類型轉換失敗的風險,因此逆變可以添加元素,不過添加的元素類型只能是指定類型和其子類,切記不要把添加元素和接收泛型類參數給弄混了。

有利有弊,雖然逆變沒有了協變只讀不寫的限制,但是讀取元素時將不能確定具體的類型,只能用 Object 來接收:



PECS

正如上面對上下界的描述,我們已經明白了大致的應用場景,當我們需要只讀不寫時,就用協變,只寫不讀,就用逆變。又想讀又想寫,我們應該指明準確的泛型類型。

註明的 PECS 原則就總結了這一點,PECS(Prodcuer extends Consumer super),也就是說,作爲元素的生產者 Prodcuer,要用協變,支持元素的讀取,而作爲消費者 Consumer,要支持逆變,支持元素的寫入。



Collections 的 copy 方法就非常好的印證了這一點:



本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VbkzJb45EPFY3Yu_iSQ25A