計算機程式的思維邏輯 (35) - 泛型 (上) - 基本概念和原理

swiftma發表於2016-10-27

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (35) - 泛型 (上) - 基本概念和原理

之前章節中我們多次提到過泛型這個概念,從本節開始,我們就來詳細討論Java中的泛型,雖然泛型的基本思維和概念是比較簡單的,但它有一些非常令人費解的語法、細節、以及侷限性,內容比較多。

所以我們分為三節,逐步來討論,本節我們主要來介紹泛型的基本概念和原理,下節我們重點討論令人費解的萬用字元,最後一節,我們討論一些細節和泛型的侷限性。

後續章節我們會介紹各種容器類,容器類可以說是日常程式開發中天天用到的,沒有容器類,難以想象能開發什麼真正有用的程式。而容器類是基於泛型的,不理解泛型,我們就難以深刻理解容器類。那,泛型到底是什麼呢?

什麼是泛型?

之前我們一直強調資料型別的概念,Java有8種基本型別,可以定義類,類相當於自定義資料型別,類之間還可以有組合和繼承。不過,在第19節,我們介紹了介面,其中提到,其實,很多時候,我們關心的不是型別,而是能力,針對介面和能力程式設計,不僅可以複用程式碼,還可以降低耦合,提高靈活性。

泛型將介面的概念進一步延伸,"泛型"字面意思就是廣泛的型別,類、介面和方法程式碼可以應用於非常廣泛的型別,程式碼與它們能夠操作的資料型別不再繫結在一起,同一套程式碼,可以用於多種資料型別,這樣,不僅可以複用程式碼,降低耦合,同時,還可以提高程式碼的可讀性和安全性。

這麼說可能比較抽象,接下來,我們通過一些例子逐步來說明。在Java中,類、介面、方法都可以是泛型的,我們先來看泛型類。

一個簡單泛型類

我們通過一個簡單的例子來說明泛型類的基本概念、實現原理和好處。

基本概念

我們直接來看程式碼:

public class Pair<T> {

    T first;
    T second;
    
    public Pair(T first, T second){
        this.first = first;
        this.second = second;
    }
    
    public T getFirst() {
        return first;
    }
    
    public T getSecond() {
        return second;
    }
}
複製程式碼

Pair就是一個泛型類,與普通類的區別,體現在:

  1. 類名後面多了一個
  2. first和second的型別都是T

T是什麼呢?T表示型別引數,泛型就是型別引數化,處理的資料型別不是固定的,而是可以作為引數傳入。

怎麼用這個泛型類,並傳遞型別引數呢?看程式碼:

Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();
複製程式碼

Pair<Integer>,這裡Integer就是傳遞的實際型別引數。

Pair類的程式碼和它處理的資料型別不是繫結的,具體型別可以變化。上面是Integer,也可以是String,比如:

Pair<String> kv = new Pair<String>("name","老馬");
複製程式碼

型別引數可以有多個,Pair類中的first和second可以是不同的型別,多個型別之間以逗號分隔,來看改進後的Pair類定義:

public class Pair<U, V> {

    U first;
    V second;
    
    public Pair(U first, V second){
        this.first = first;
        this.second = second;
    }
    
    public U getFirst() {
        return first;
    }

    public V getSecond() {
        return second;
    }
}
複製程式碼

可以這樣使用:

Pair<String,Integer> pair = new Pair<String,Integer>("老馬",100);
複製程式碼

<String,Integer>既出現在了宣告變數時,也出現在了new後面,比較囉嗦,Java支援省略後面的型別引數,可以這樣:

Pair<String,Integer> pair = new Pair<>("老馬",100);
複製程式碼

基本原理

泛型型別引數到底是什麼呢?為什麼一定要定義型別引數呢?定義普通類,直接使用Object不就行了嗎?比如,Pair類可以寫為:

public class Pair {

    Object first;
    Object second;
    
    public Pair(Object first, Object second){
        this.first = first;
        this.second = second;
    }
    
    public Object getFirst() {
        return first;
    }
    
    public Object getSecond() {
        return second;
    }
}    
複製程式碼

使用Pair的程式碼可以為:

Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();

Pair kv = new Pair("name","老馬");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();
複製程式碼

這樣是可以的。實際上,Java泛型的內部原理就是這樣的。

我們知道,Java有Java編譯器和Java虛擬機器,編譯器將Java原始碼轉換為.class檔案,虛擬機器載入並執行.class檔案。對於泛型類,Java編譯器會將泛型程式碼轉換為普通的非泛型程式碼,就像上面的普通Pair類程式碼及其使用程式碼一樣,將型別引數T擦除,替換為Object,插入必要的強制型別轉換。Java虛擬機器實際執行的時候,它是不知道泛型這回事的,它只知道普通的類及程式碼。

再強調一下,Java泛型是通過擦除實現的,類定義中的型別引數如T會被替換為Object,在程式執行過程中,不知道泛型的實際型別引數,比如Pair<Integer>,執行中只知道Pair,而不知道Integer,認識到這一點是非常重要的,它有助於我們理解Java泛型的很多限制。

Java為什麼要這麼設計呢?泛型是Java 1.5以後才支援的,這麼設計是為了相容性而不得已的一個選擇。

泛型的好處

既然只使用普通類和Object就是可以的,而且泛型最後也轉換為了普通類,那為什麼還要用泛型呢?或者說,泛型到底有什麼好處呢?

主要有兩個好處:

  • 更好的安全性
  • 更好的可讀性

語言和程式設計的一個重要目標是將bug儘量消滅在搖籃裡,能消滅在寫程式碼的時候,就不要等到程式碼寫完,程式執行的時候。

只使用Object,程式碼寫錯的時候,開發環境和編譯器不能幫我們發現問題,看程式碼:

Pair pair = new Pair("老馬",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();
複製程式碼

看出問題了嗎?寫程式碼時,不小心,型別弄錯了,不過,程式碼編譯時是沒有任何問題的,但,執行時,程式丟擲了型別轉換異常ClassCastException。

如果使用泛型,則不可能犯這個錯誤,如果這麼寫程式碼:

Pair<String,Integer> pair = new Pair<>("老馬",1);
Integer id = pair.getFirst();
String name = pair.getSecond();
複製程式碼

開發環境如Eclipse會提示你型別錯誤,即使沒有好的開發環境,編譯時,Java編譯器也會提示你。這稱之為型別安全也就是說,通過使用泛型,開發環境和編譯器能確保你不會用錯型別,為你的程式多設定一道安全防護網

使用泛型,還可以省去繁瑣的強制型別轉換,再加上明確的型別資訊,程式碼可讀性也會更好。

容器類

泛型類最常見的用途是作為容器類,所謂容器類,簡單的說,就是容納並管理多項資料的類。陣列就是用來管理多項資料的,但陣列有很多限制,比如說,長度固定,插入、刪除操作效率比較低。計算機技術有一門課程叫資料結構,專門討論管理資料的各種方式。

這些資料結構在Java中的實現主要就是Java中的各種容器類,甚至,Java泛型的引入主要也是為了更好的支援Java容器。後續章節我們會詳細討論主要的Java容器,本節我們先自己實現一個非常簡單的Java容器,來解釋泛型的一些概念。

我們來實現一個簡單的動態陣列容器,所謂動態陣列,就是長度可變的陣列,底層陣列的長度當然是不可變的,但我們提供一個類,對這個類的使用者而言,好像就是一個長度可變的陣列,Java容器中有一個對應的類ArrayList,本節我們來實現一個簡化版。

來看程式碼:

public class DynamicArray<E> {
    private static final int DEFAULT_CAPACITY = 10;

    private int size;
    private Object[] elementData;

    public DynamicArray() {
        this.elementData = new Object[DEFAULT_CAPACITY];
    }

    private void ensureCapacity(int minCapacity) {
        int oldCapacity = elementData.length;
        if(oldCapacity>=minCapacity){
            return;
        }
        int newCapacity = oldCapacity * 2;
        if (newCapacity < minCapacity)
            newCapacity = minCapacity;
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    public void add(E e) {
        ensureCapacity(size + 1);
        elementData[size++] = e;
    }

    public E get(int index) {
        return (E)elementData[index];
    }
    
    public int size() {
        return size;
    }

    public E set(int index, E element) {
        E oldValue = get(index);
        elementData[index] = element;
        return oldValue;
    }

}    
複製程式碼

DynamicArray就是一個動態陣列,內部程式碼與我們之前分析過的StringBuilder類似,通過ensureCapacity方法來根據需要擴充套件陣列。作為一個容器類,它容納的資料型別是作為引數傳遞過來的,比如說,存放Double型別:

DynamicArray<Double> arr = new DynamicArray<Double>();
Random rnd = new Random();
int size = 1+rnd.nextInt(100);
for(int i=0; i<size; i++){
    arr.add(Math.random());
}

Double d = arr.get(rnd.nextInt(size));
複製程式碼

這就是一個簡單的容器類,適用於各種資料型別,且型別安全。本節後面和後面兩節還會以DynamicArray為例進行擴充套件,以解釋泛型概念。

具體的型別還可以是一個泛型類,比如,可以這樣寫:

DynamicArray<Pair<Integer,String>> arr = new DynamicArray<>()
複製程式碼

arr表示一個動態陣列,每個元素是Pair<Integer,String>型別。

泛型方法

除了泛型類,方法也可以是泛型的,而且,一個方法是不是泛型的,與它所在的類是不是泛型沒有什麼關係。

我們看個例子:

public static <T> int indexOf(T[] arr, T elm){
    for(int i=0; i<arr.length; i++){
        if(arr[i].equals(elm)){
            return i;
        }
    }
    return -1;
}
複製程式碼

這個方法就是一個泛型方法,型別引數為T,放在返回值前面,它可以這麼呼叫:

indexOf(new Integer[]{1,3,5}, 10)
複製程式碼

也可以這麼呼叫:

indexOf(new String[]{"hello","老馬","程式設計"}, "老馬")
複製程式碼

indexOf表示一個演算法,在給定陣列中尋找某一個元素,這個演算法的基本過程與具體資料型別沒有什麼關係,通過泛型,它就可以方便的應用於各種資料型別,且編譯器保證型別安全

與泛型類一樣,型別引數可以有多個,多個以逗號分隔,比如:

public static <U,V> Pair<U,V> makePair(U first, V second){
    Pair<U,V> pair = new Pair<>(first, second);
    return pair;
}
複製程式碼

與泛型類不同,呼叫方法時一般並不需要特意指定型別引數的實際型別是什麼,比如呼叫makePair:

makePair(1,"老馬");
複製程式碼

並不需要告訴編譯器U的型別是Integer,V的型別是String,Java編譯器可以自動推斷出來。

泛型介面

介面也可以是泛型的,我們之前介紹過的Comparable和Comparator介面都是泛型的,它們的程式碼如下:

public interface Comparable<T> {
    public int compareTo(T o);
}
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
}
複製程式碼

與前面一樣,T是型別引數。實現介面時,應該指定具體的型別,比如,對Integer類,實現程式碼是:

public final class Integer extends Number implements Comparable<Integer>{
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    //...
}
複製程式碼

通過implements Comparable<Integer>,Integer實現了Comparable介面,指定了實際型別引數為Integer,表示Integer只能與Integer物件進行比較。

再看Comparator的一個例子,String類內部一個Comparator的介面實現為:

private static class CaseInsensitiveComparator
        implements Comparator<String> {
    public int compare(String s1, String s2) {
        //....
    }
}
複製程式碼

這裡,指定了實際型別引數為String。

型別引數的限定

在之前的介紹中,無論是泛型類、泛型方法還是泛型介面,關於型別引數,我們都知之甚少,只能把它當做Object,但Java支援限定這個引數的一個上界,也就是說,引數必須為給定的上界型別或其子型別,這個限定是通過extends這個關鍵字來表示的。

這個上界可以是某個具體的類,或者某個具體的介面,也可以是其他的型別引數,我們逐個來看下其應用。

上界為某個具體類

比如說,上面的Pair類,可以定義一個子類NumberPair,限定兩個型別引數必須為Number,程式碼如下:

public class NumberPair<U extends Number, V extends Number> extends Pair<U, V> {

    public NumberPair(U first, V second) {
        super(first, second);
    }
}    
複製程式碼

限定型別後,就可以使用該型別的方法了,比如說,對於NumberPair類,first和second變數就可以當做Number進行處理了,比如可以定義一個求和方法,如下所示:

public double sum(){
    return getFirst().doubleValue()
            +getSecond().doubleValue();
}
複製程式碼

可以這麼用:

NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();
複製程式碼

限定型別後,如果型別使用錯誤,編譯器會提示。

指定邊界後,型別擦除時就不會轉換為Object了,而是會轉換為它的邊界型別,這也是容易理解的。

上界為某個介面

在泛型方法中,一種常見的場景是限定型別必須實現Comparable介面,我們來看程式碼:

public static <T extends Comparable> T max(T[] arr){
    T max = arr[0];
    for(int i=1; i<arr.length; i++){
        if(arr[i].compareTo(max)>0){
            max = arr[i];
        }
    }
    return max;
}
複製程式碼

max方法計算一個泛型陣列中的最大值,計算最大值需要進行元素之間的比較,要求元素實現Comparable介面,所以給型別引數設定了一個上邊界Comparable,T必須實現Comparable介面。

不過,直接這麼寫程式碼,Java中會給一個警告資訊,因為Comparable是一個泛型介面,它也需要一個型別引數,所以完整的方法宣告應該是:

public static <T extends Comparable<T>> T max(T[] arr){

//...

}
複製程式碼

<T extends Comparable<T>>是一種令人費解的語法形式,這種形式稱之為遞迴型別限制,可以這麼解讀,T表示一種資料型別,必須實現Comparable介面,且必須可以與相同型別的元素進行比較。

上界為其他型別引數

上面的限定都是指定了一個明確的類或介面,Java支援一個型別引數以另一個型別引數作為上界。為什麼需要這個呢?

我們看個例子,給上面的DynamicArray類增加一個例項方法addAll,這個方法將引數容器中的所有元素都新增到當前容器裡來,直覺上,程式碼可以這麼寫:

public void addAll(DynamicArray<E> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}
複製程式碼

但這麼寫有一些侷限性,我們看使用它的程式碼:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);
複製程式碼

numbers是一個Number型別的容器,ints是一個Integer型別的容器,我們希望將ints新增到numbers中,因為Integer是Number的子類,應該說,這是一個合理的需求和操作。

但,Java會在number.addAll(ints)這行程式碼上提示編譯錯誤,提示,addAll需要的引數型別為DynamicArray<Number>,而傳遞過來的引數型別為DynamicArray<Integer>,不適用,Integer是Number的子類,怎麼會不適用呢?

事實就是這樣,確實不適用,而且是很有道理的,假設適用,我們看下會發生什麼。

DynamicArray<Integer> ints = new DynamicArray<>();
//假設下面這行是合法的
DynamicArray<Number> numbers = ints;

numbers.add(new Double(12.34));
複製程式碼

那最後一行就是合法的,這時,DynamicArray<Integer>中就會出現Double型別的值,而這,顯然就破壞了Java泛型關於型別安全的保證。

我們強調一下,雖然Integer是Number的子類,但DynamicArray<Integer>並不是DynamicArray<Number>的子類,DynamicArray<Integer>的物件也不能賦值給DynamicArray<Number>的變數,這一點初看上去是違反直覺的,但這是事實,必須要理解這一點。

不過,我們的需求是合理的啊,將Integer新增到Number容器中,這沒有問題啊。這個問題,可以通過型別限定,這樣來解決:

public <T extends E> void addAll(DynamicArray<T> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}
複製程式碼

E是DynamicArray的型別引數,T是addAll的型別引數,T的上界限定為E,這樣,下面的程式碼就沒有問題了:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);
複製程式碼

對於這個例子,這個寫法有點囉嗦,下節我們會看到一種簡化的方式。

小結

泛型是計算機程式中一種重要的思維方式,它將資料結構和演算法與資料型別相分離,使得同一套資料結構和演算法,能夠應用於各種資料型別,而且還可以保證型別安全,提高可讀性。在Java中,泛型廣泛應用於各種容器類中,理解泛型是深刻理解容器的基礎。

本節介紹了泛型的基本概念,包括泛型類、泛型方法和泛型介面,關於型別引數,我們介紹了多種上界限定,限定為某具體類、某具體介面、或其他型別引數。泛型類最常見的用途是容器類,我們實現了一個簡單的容器類DynamicArray,以解釋泛型概念。

在Java中,泛型是通過型別擦除來實現的,它是Java編譯器的概念,Java虛擬機器執行時對泛型基本一無所知,理解這一點是很重要的,它有助於我們理解Java泛型的很多侷限性。

關於泛型,Java中有一個萬用字元的概念,語法非常令人費解,而且容易混淆,下一節,我們力圖對它進行清晰的剖析。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (35) - 泛型 (上) - 基本概念和原理

相關文章