2018年11月30日 星期五

Java SE 入門教學 - 泛型(Generic)

更新時間:11/30/2018

前言

泛型((Generic)在 JAVA 中有很重要的地位,在物件導向編程及各種設計模式中有非常廣泛的應用。

什麼是泛型?為什麼要使用泛型?

泛型,即「參數化型態」。一提到參數,最熟悉的就是定義方法時形式參數(formal parameter, parameter, 參數),然後調用此方法時傳遞實際參數(actual parameter, arguments, 引數)。那麼參數化型態怎麼理解呢?顧名思義,就是將型態由原來具體的型態參數化,類似於方法中的變數參數,此時型態也定義成形式參數(可以稱之為形式型態參數),然後在使用/調用時傳入具體的型態(實際型態參數)。

泛型的本質是為了參數化型態(在不創建新型態的情況下,通過泛型指定的不同型態來控制形式參數具體限制的型態)。也就是說在泛型使用過程中,操作的數據型態被指定為一個參數,這種參數型態可以用在類別、介面和方法中,分別被稱為泛型類別(Generic Class)、泛型介面(Generic Interface)、泛型方法(Generic Method)。



一、沒有泛型會怎樣?

先看下面這段代碼,我們實現兩個能夠設置點座標的類別,分別設置 Integer 型態的點座標和 Float 類型的點座標:

那現在有個問題:大家有沒有發現,他們除了變數型態不一樣,一個是 Integer,一個是 Float 以外,其它並沒有什麼區別!那我們能不能合併成一個呢?

答案是可以的,因為 Integer 和 Float 都是派生自 Object 的,我們用下面這段代碼代替:

即全部都用 Object 來代替所有的子類。
在使用的時候是這樣的:

在設置的時候,使用 new Integer(100) 來創建一個 Integer。

integerPoint.setX(new Integer(100));

然後在取值的時候,進行強制轉換:

Integer integerX = (Integer)integerPoint.getX();

由於我們設置的時候,是設置 Integer,所以在取值的時候,強制轉換是不會出錯的。

同理,FloatPoint 的設置和取值也是類似的,代碼如下:

但問題來了:注意,注意,我們這裡使用了強制轉換,我們這裡 setX() 和 getX() 寫得很近,所以我們明確的知道我們傳進去的是 Float 型態,那如果我們記錯了呢?

比如我們改成下面這樣,編譯時會報錯嗎?

不會!我們問題的關鍵在於這句:

強制轉換時,會不會出錯。因為編譯器也不知道你傳進去的是什麼,而 floatPoint.getX() 返回的類型是 Object,所以編譯時,將 Object 強制轉成 String 是成立的。必然不會報錯。

而在執行時,則不然,在執行時,floatPoint 實例中明明傳進去的是 Float 型態的變數,非要把它強制轉成 String 型態,肯定會報型態轉換錯誤的!

那有沒有一種辦法在編譯階段,即能合併成同一個,又能在編譯時檢查出,傳進去的型態不對呢?有,這就是泛型。



二、泛型類別(Generic Class)

在 JAVA 中的泛型相似於 C++ 中的樣板(Template),我們同樣使用尖括號(angle brackets)「<>」來指定參數型態,建立一個泛型類別其語法如下:

// 建立一個泛型物件的實例
BaseType<Type> obj = new BaseType<Type>();

注意:在參數型態中,我們不能使用基礎型態,如
  「int」`「char」、「double」等。

範例 1

2.1 定義泛型:Point<T>

首先,大家可以看到 Point<T>,即在類名後面加一個尖括號,括號裡是一個大寫字母。這裡寫的是 T,其實這個字母可以是任何大寫字母,大家這裡先記著,可以是任何大寫字母,意義是相同的。

2.2 泛型類別中使用泛型

這個 T 表示繼承自 Object 類的任何類別,比如 String、Integer、Double 等等。這裡要注意的是,T 一定是繼承於 Object 類別的。為方便起見,大家可以在這裡把 T 當成 String,即 String 在類別中怎麼用,那 T 在類別中就可以怎麼用!所以下方的:定義變數、作為返回值、作為參數傳入的定義就很容易理解了。

// 定義變數
private T x;

// 作為返回值
public T getX(){
  return x;
}

// 作為參數傳入
public void setX(T x){
  this.x = x;
}

2.3 外部類別使用泛型類別

下方為泛型類別的用法:

//IntegerPoint使用
Point<Integer> p1 = new Point<Integer>();
p1.setX(new Integer(100));
System.out.println(p1.getX());

//FloatPoint使用
Point<Float> p2 = new Point<Float>();
p2.setX(new Float(100.12f));
System.out.println(p2.getX());

首先,是建立一個實例。

這裡與普通建立物件實例的不同之點在於,普通建立物件實例是這樣的:Point p = new Point();

但,因為我們構造類別時,是這樣寫的 class Point<T>,所以在使用的時候也要在 Point 後加上型態來定義 T 代表的意義。建立物件實例如下:

//IntegerPoint使用
Point<Integer> p1 = new Point<Integer>();

//FloatPoint使用
Point<Float> p2 = new Point<Float>();

尖括號中,你傳進去的是什麼,T 就代表什麼型態。這就是泛型的最大作用,我們只需要考慮邏輯實現,就能拿給各種類別來使用。

2.4 使用泛型的優勢

2.4.1 不用強制轉型。

2.4.2 使用型態不對時,在編譯時期會報錯。



三、多泛型參數型態及字母規範

3.1 多泛型參數型態

在上節中,我們只定義了一個泛型參數 T,那如果我們需要使用多個泛型要怎麼辦呢?使用方式如下:

class MorePoint<T, U> {
}

範例 2:在 Point 上再另加一個字段 name,也用泛型來表示

3.2 字母規範

在定義泛型類別時,指定泛型的變數是任意一個大寫字母。但為了提高可讀性,大家還是用有意義的字母比較好,一般來講,在不同的情境下使用的字母意義如下:

  • E:Element,常用在 Java Collection 裡,如:List<E>、Iterator<E>、Set<E>
  • K,V:Key, Value,代表 Map 的鍵值對
  • N:Number,數字
  • T:Type,型態,如:String、Integer 等等

如果這些還不夠用,那就自己隨便取吧,反正26個英文字母。再重複一遍,使用哪個字母是沒有特定意義的!只是為了提高可讀性!



四、泛型介面(Generic Interface)定義及使用

在介面上定義泛型與在類別中定義泛型是一樣的,代碼如下:

//在接口上定義泛型
interface Info<T> {
  //定義抽象方法,抽象方法的返回值就是泛型型態
  public T getVar();
  public void setVar(T x);
}

4.1 非泛型類別 實作 泛型介面

範例 3

要清楚的一點是 InfoImpl 不是一個泛型類別!因為它類別名字後沒有<T>。

然後在這裡我們將 Info<String> 中的泛型變數 T 定義填充成為 String 型態。所以在覆寫 setVar() 和 getVar() 時,編譯器直接生成 String 型態的覆寫函數。最後在使用時,傳入 String 類型的字符串來建立 InfoImpl 實例,然後調用它的函數即可。

4.2 泛型類別 實作 泛型介面

在這個類別中,我們構造了一個泛型類別 InfoImpl<T>,然後把泛型變數 T 傳給了 Info<T>,這說明介面和泛型類別使用的都是同一個泛型變數。然後在使用時,就是構造一個泛型類別的實例的過程,使用過程也不變。

範例 4

使用泛型類別來繼承泛型介面的作用就是讓用戶來定義介面所使用的變數型態,而不是像方法一那樣,在類別中寫死。

您可以擴充一個泛型類別,保留其型態持有者,並新增自己的型態持有者。如果決定要保留型態持有者,則父類別上宣告的型態持有者數目在繼承下來時必須寫齊全。例如範例5中,父介面 Info 上出現的 U 在子類別 InfoImpl 中都要出現。

範例 5

構造一個多泛型參數型態,並實作 Info 介面。

//定義泛型介面的子類別
class InfoImpl<T, K, U> implements Info<U> {
  private U var; //定義屬性
  private T x;
  private K y;

  public InfoImpl(U var){ //通過構造方法設置屬性內容
    this.setVar(var);
  }

  @Override
  public void setVar(U var){
    this.var = var;
  }
  @Override
  public U getVar(){
    return this.var;
  }
}


五、泛型方法(Generic Method)定義及使用

可以調用不同型態參數的一個通用方法聲明。基於傳遞給泛型方法的參數型態,編譯器適當地處理每個方法調用。其代碼如下:

[存取修飾子] <T> 傳回值型態 方法名稱(T a){
}

範例 6

方法一,可以像普通方法一樣,直接傳值,任何值都可以(但必須是繼承 Object 類別的型態,比如 String、Integer 等),函數方法會在內部根據傳進去的參數來識別當前 T 的型態。但盡量不要使用這種隱式的傳遞方式,代碼不利於閱讀和維護。因為從外觀根本看不出來你調用的是一個泛型函數。

方法二,與方法一不同的地方在於,在調用方法前加了一個 <String> 來指定傳給 <T> 的值,如果加了這個 <String> 來指定參數的值的話,那 StaticMethod() 函數方法裡所有用到的 T 型態也就是強制指定了是 String 型態。這是我們建議使用的方式。

上方例子中返回值都是 void,但現實中不可能都是 void,有時,我們需要將泛型變數返回,比如下面這個函數方法:

public static <T> List<T> parseArray(String response, Class<T> object){
  List<T> modeList = JSON.parseArray(response, object);
  return modeList;
}


六、限制泛型可用型態

在定義泛型類別時,預設您可以使用任何的型態來實例化泛型類別中的型態持有者,但假設您想要限制使用泛型類別時,只能用某個特定型態其子類別來實例化型態持有者的話呢?

我們可以使用「extends」關鍵字,不分限定的對象是介面(Interface)或類別(Class)。

extends:

class CollectionGeneric <T extends Collection> {
}


CollectionGeneric 在宣告泛型型態持有者時,一併指定這個持有者實例化的物件,必須是實作 java.util.Collection 介面的型態。

編譯錯誤 1:String 不是 Collection 的子類別。

TestGeneric6.java:8: error: type argument String is not within bounds of type-variable T
                new CollectionGeneric<String>();
                                      ^
  where T is a type-variable:
    T extends Collection declared in class CollectionGeneric
1 error

範例 7:丟入 Collection 的子類別,並輸出類別名。



七、型態通配字元(Wildcard)

?」是泛型的萬用字元,表示任意的物件型態。

「?」還可以與「extends」和「super」兩個關鍵字合用。

首先我們先看一下泛型的使用方式,假設您使用(範例一) Point 泛型類別來宣告如下的名稱:

Point<Integer> p1 = null;
Point<Float> p2 = null;

那麼名稱 p1 就只能參考 Point<Integer> 型態的實例,而名稱 p2 只能參考 Point<Float> 型態的實例,也就是說下面的方式是可行的:

p1 = new Point<Integer>();
p2 = new Point<Float>();

但,現在您不想使用兩個變數,只希望只用一個物件參考變數就可以接受以上所指定的實例該怎麼辦呢?此時就需要搭配型態通配字元「?」來使用。例如:

Point<? extends Number> p = null;
p = new Point<Integer>();
p = new Point<Float>();

如此,物件參考變數 p 就可以參考所有繼承 Number 類別的泛型型態了!
所以型態通配字元的使用時機在於宣告變數時使用


7.1 型態通配字元是 Object 類別嗎?

肯定不是!型態通配字元是可以接受傳入任何類別,不單單只是 Object 類別,而是所有的 JAVA 物件了!那為什麼不直接使用 Point 宣告就好了,何必要用 Point<?> 來宣告?使用通配字元有點要注意的是,透過使用通配字元宣告的名稱所參考的物件,您沒辦法再對它加入新的型態,參考完成,型態也就固定了!

範例 8:只宣告一次物件參考變數,參考所有的物件實例。


7.2 型態通配字元搭配 extends 與 super

<? extends someClass><? super someClass> 是 Java 泛型中的"通配符(Wildcards)"和"邊界(Bounds)"的概念。

  • <? extends someClass>:是指「上界通配符(Upper Bounds Wildcards)
  • <? super someClass>:是指「下界通配符(Lower Bounds Wildcards)

我們先建造一個最簡單的容器 Plate 類別。盤子裡可以放一個泛型的"東西"。我們可以對這個東西做最簡單的"放" set() 和"取" get() 的動作。

盤子裡要放點東西,我們建構一個有繼承關係的食物,食物分成水果和肉類,水果有蘋果和香蕉,肉類有豬肉和牛肉,蘋果還有兩種青蘋果和紅蘋果。

現在我定義一個"水果盤子",邏輯上水果盤子當然可以裝蘋果。

Plate<Fruit> p = new Plate<Apple>(new Apple());

但實際上 Java 編譯器不允許這個操作。會報錯,"裝蘋果的盤子"無法轉換成"裝水果的盤子"。

error: incompatible types: Plate<Apple> cannot be converted to Plate<Fruit>

實際上,編譯器腦袋裡認定的邏輯是這樣的:

  • 蘋果 IS-A 水果
  • 裝蘋果的盤子 NOT-IS-A 裝水果的盤子

所以,就算容器裡裝的東西之間有繼承關係,但容器之間是沒有繼承關係的。所以我們不可以把 Plate<Apple> 的引用傳遞給 Plate<Fruit>。

為了讓泛型用起來更舒服,Sun 的大腦袋們就想出了 <? extends someClass> 和 <? super someClass> 的辦法,來讓"水果盤子"和"蘋果盤子"之間發生關係。

7.2.1 上界通配符(Upper Bounds Wildcards)

Plate<? extends Fruit>

一個能放水果以及所有繼承水果的子類別的盤子。再直白點就是:什麼水果都能放的盤子。這和我們人類的邏輯就比較接近了!
藍色的區塊是合法的建立物件實例,其餘的會產生編譯錯誤。

7.2.2 下界通配符(Lower Bounds Wildcards)

Plate<? super Fruit>

表達相反的概念:一個能放水果以及所有水果的父類別的盤子。
紅色的區塊是合法的建立物件實例,其餘的會產生編譯錯誤。


7.3 上下界通配符的副作用

邊界讓 Java 不同泛型之間的轉換更容易了。但不要忘記,這樣的轉換也有一定的副作用。那就是容器的部分功能可能失效。

以剛才的 Plate 為例。我們可以對盤子做兩件事,往盤子裡 set() 新東西,以及從盤子裡 get() 東西。

// Generic
class Plate<T> {
  private T item;
  public Plate(T t){item=t;}
  public void set(T t){item=t;}
  public T get(){return item;}
}

7.3.1 上界<? extends someClass>不能往裡存,只能往外取

<? extends Fruit>會使往盤子裡放東西的 set() 方法失效;但取東西 get() 方法還有效。

因為創建物件實例時,是所有 Fruit 的子類別都可以創建,所以編譯器只知道容器內是 Fruit 或者它的子類別,但具體是什麼類型不知道。可能是 Fruit?可能是 Apple?也可能是 Banana、RedApple、GreenApple?

class TestGeneric9 {
  public static void main(String[] args){
    Plate<? extends Fruit> p = new Plate<Apple>(new Apple());

    //不能存入任何元素
    p.set(new Fruit()); //Error
    p.set(new Apple()); //Error

    //讀取出來的東西只能存放在 Fruit 或它的父類別裡
    Fruit newFruit1 = p.get();
    Food newFruit2 = p.get();
    Object newFruit3 = p.get();
    Apple newFruit4 = p.get(); //Error
  }
}

編譯器在看到創建物件實例後面用 Plate<Apple> 賦值以後,盤子裡沒有被標上有"蘋果",而是標上一個占位符:CAP#1,來表示捕獲一個 Fruit 或 Fruit 的子類別,具體是什麼不知道。然後無論是想往裡面插入 Apple 或者 Meat 或者 Fruit 編譯器都不知道能不能和這個 CAP#1 匹配,所以就都不允許。

所以通配符<?>和型態參數<T>的區別就在於,對編譯器來說所有的 T 都代表同一種型態;但通配符<?>沒有這種約束,Plate<?> 單純的就表示:盤子裡放了一個東西,是什麼我不知道

7.3.2 下界<? super someClass>不影響往裡存,但往外取只能放在 Object 型態裡

使用下界<? super Fruit>會使從盤子裡取東西的 get() 方法部分失效,只能存放到 Object 型態裡,而 set() 方法正常。

因為創建物件實例時,是所有 Fruit 的父類別都可以創建,所以編譯器只知道容器內是 Fruit 或是它的父類別,但實際是什麼類型不知道。所以往裡面存只能夠是 Fruit 的子類別;但往外讀取元素時,只有所有類別的根類別 Object 類別才能裝下,但這樣的話,元素的型態信息就全部丟失了!

class TestGeneric10 {
  public static void main(String[] args){
    Plate<? super Fruit> p = new Plate<Food>(new Food());

    //存入元素正常
    p.set(new Fruit());
    p.set(new Apple());

    //不能存入 Fruit 的父類別
    p.set(new Food()); //Error

    //讀取出來的東西只能存放在 Object 類別裡
    Fruit newFruit1 = p.get(); //Error
    Food newFruit2 = p.get(); //Error
    Object newFruit3 = p.get();
    Apple newFruit4 = p.get(); //Error
  }
}

7.3.3 PECS原則

什麼是 PECS(Producer Extends Consumer Super)原則?

1.頻繁往外讀取內容的,適合用上界 Extends。
2.經常往裡插入的,適合用下界 Super。



八、總結

泛型,「參數化型態」。有三種泛型類別(Generic Class)、泛型介面(Generic Interface)、泛型方法(Generic Method)。使用泛型的優勢有「不用強制轉型」和「使用型態不對時,在編譯時期會報錯」。

使用型態參數時,使用有意義的字母可提高可讀性。

可使用「限制泛型可用型態(T extends someClass)」規範可使用的物件實例型態,排除不需要的型態。

可在宣告物件變數時使用「型態通配字元(?)」,方便切換不同泛型物件實例;但會有一些副作用。





8 則留言:

  1. Very nice article. Thanks a lot.

    回覆刪除
  2. 7.3.2往裡面存只能是...的子類別 感覺矛盾了

    回覆刪除
    回覆
    1. 親愛的讀者您好:
      這邊的觀念沒有錯喔~
      因為建立物件實例後,可以確定該實例物件一定是Fruit或其父類別的泛型,能夠存入的型別能確定的也只能是Fruit的子類別,如果存入非Fruit的子類別,會發生存入的類別是沒有任何繼承關係的類別。
      如需補充,請不吝告知,謝謝!

      刪除