定義與使用泛型

June 21, 2022

在〈final/Object/instanceof〉實作過一個 ArrayList,由於事先不知道被收集物件之形態,因此內部實作時,都是使用 Object 來參考被收集之物件,取回物件時也是以 Object 型態傳回,若你想針對某類別定義的行為操作時,必須告訴編譯器,讓物件重新扮演該型態。例如:

...
    static void printUpperCase(ArrayList names) {
        for(var i = 0; i < names.size(); i++) {
            var name = (String) names.get(i);
            System.out.println(name.toUpperCase());
        }       
    }
...

收集物件時,考慮到收集各種物件之需求,因而內部實作採用 Object 參考收集之物件,執行時期被收集的物件會失去形態資訊,也因此取回物件之後,必須自行記得物件的真正型態,並在語法上告訴編譯器讓物件重新扮演為自己的型態。

定義泛型類別

實際上,有收集物件的需求時,多半會收集同一種類型的物件,泛型(Generics)語法讓你在設計 API 時可以指定類別或方法支援泛型,而使用 API 的客戶端在語法上會更為簡潔,並得到編譯時期檢查。

以〈final/Object/instanceof〉實作過的 ArrayList 為例,,可加入泛型語法:

package cc.openhome;

import java.util.Arrays;

public class ArrayList<E> {
    private Object[] list;
    private int next;
   
    public ArrayList(int capacity) {
        list = new Object[capacity];
    }

    public ArrayList() {
        this(16);
    }

    public void add(E e) {
        if(next == list.length) {
            list = Arrays.copyOf(list, list.length * 2);
        }
        list[next++] = e;
    }
    
    public E get(int index) {
        return (E) list[index];
    }
    
    public int size() {
        return next;
    }
}

類別名稱旁出現了角括號 <E>,這表示此類別支援泛型,實際加入 ArrayList 的物件會客戶端宣告的 E 型態,E 只是個型態參數(表示 Element),高興的話,你可以用 TKV 等參數名稱。

由於使用 <E> 定義型態參數,在需要編譯器檢查型態的地方,都可以使用 E,像是 add 方法必須檢查傳入的物件型態是 Eget 方法必須轉換為E型態。

使用泛型語法,會對 API 的實作者造成一些語法上的麻煩,但對客戶端會多一些友善。例如:

...
ArrayList<String> names = new ArrayList<String>();
names.add("Justin");
names.add("Monica");
String name1 = names.get(0);
String name2 = names.get(1);
...

宣告與建立物件時,可使用角括號告知編譯器,這個物件收集的都會是 String,而取回之後也會是 String,不用再使用括號轉換型態。如果實際上加入了不是 String 的東西,因為你告訴編譯器,這個 ArrayList 會收集的物件都是 String,若你收集非 String 的物件,編譯器就會檢查出這個錯誤。

編譯的警訊

使用了宣告泛型的類別而不做型態宣告,型態部份會使用 Object,也就是回歸沒有使用泛型前的做法,例如:

...
var names = new ArrayList();
names.add("Justin");
names.add("Monica");
var name1 = (String) names.get(0);
var name2 = (String) names.get(1);
...

編譯時會出現警告訊息:

Note: Main.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

使用 javac 時再加上 -Xlint:unchecked 就會告訴你詳細原因,主要是編譯器發現這個類別可以使用泛型,貼心地提醒你,是不是要使用,以避免發生非受檢(unckecked)的 ClassCastExcetpion 例外:

Main.java:13: warning: [unchecked] unchecked call to add(E) as a member of
the raw type ArrayList
names.add("Justin");
         ^
  where E is a type-variable:
    E extends Object declared in class ArrayList
Main.java:14: warning: [unchecked] unchecked call to add(E) as a member of
the raw type ArrayList
names.add("Monica");
         ^
  where E is a type-variable:
    E extends Object declared in class ArrayList
2 warnings

實際上你在編譯上頭的 ArrayList 時,也會出現警告訊息,因為在使用泛型時,get 方法傳回物件時還是用了 CAST 語法,編譯時加上 -Xlint:unchecked 時就可以看到原因:

ArrayList.java:23: warning: [unchecked] unchecked cast
        return (E) list[index];
                       ^
  required: E
  found:    Object
  where E is a type-variable:
    E extends Object declared in class ArrayList
1 warning

這個部份的 CAST 是必要的,如果想避免編譯時看到這個警訊,可以在 get 上標註 @SuppressWarnings("unchecked"),告訴編譯器忽略這個可能的錯誤:

...
    @SuppressWarnings("unchecked")
    public E get(int index) {
        return (E) list[index];
    }
...

介面與泛型

若介面支援泛型,在實作時也會比較方便,例如想排序陣列的話,可以使用 java.util.Arrays 的靜態 sort 方法,若想指定元素順序,可以指定 Comparator 實作物件,Comparator 中有關泛型的宣告是這樣的:

...
public interface Comparator<T> {
    int compare(T o1, T o2);
    ...
}

這表示實作介面時,可以指定 T 代表的型態,而 compare 就可以直接套用 T 型態。例如:

package cc.openhome;

import java.util.*;

class ReversedStringOrder implements Comparator<String> {
    @Override
    public int compare(String s1, String s2) {
        return -s1.compareTo(s2);
    }
}

public class Main {
    public static void main(String[] args) {
        String[] words = {"B", "X", "A", "M", "F", "W", "O"};
        Arrays.sort(words, new ReversedStringOrder());
        for(String word : words) {
            System.out.println(word);
        }
    }
}

Arrays.sort 該行,如果想使用匿名內部類別來實現,可以如下:

Arrays.sort(words, new Comparator<String>() {
    @Override
    public int compare(String s1, String s2) {
        return -s1.compareTo(s2);
    }
});

可以看到,可讀性並不好,實際上我們只關心 s1s2 的順序,可以使用 Lambda 語法簡化為以下:

Arrays.sort(words, (s1, s2) -> -s1.compareTo(s2));

這在可讀性上會有很大的助益,有關於 Lambda 會在之後詳述。

再來看一下以下程式片段:

ArrayList<String> words = new ArrayListList<String>();

你會不會覺得有點囉嗦呢?明明宣告 words 已經使用 ArrayList<String> 告訴編譯器,words 參考的物件中,都會是 String 了,為什麼建構 ArrayList 時,還要用 ArrayList<String> 再告知呢?確實,如下撰寫就可以了:

ArrayList<String> words = new ArrayList<>();

如果是 Java 11 以後的版本,可以使用 var

var words = new ArrayListList<String>();

靜態方法與泛型

如果現在想寫個 asArrayList 方法,可指定不定長度引數,將之轉換為 ArrayList,可以如下:

package cc.openhome;

public class Util {    
    public static <T> ArrayList<T> asList(T... a) {
        ArrayList<T> arrLt = new ArrayList<>();
        for(T t : a) {
            arrLt.add(t);
        }
        return arrLt;
    }
}

想使用這個 asList 方法,完整的泛型宣告語法如下:

ArrayList<String> arrLt = Util.<String>asList("B", "X", "A", "M", "F", "W", "O");

實際上,編譯器可以從 asList 的引數,瞭解到型態參數 T 實際上是 String 型態,因此,可以簡化撰寫為:

ArrayList<String> arrLt = Util.asList("B", "X", "A", "M", "F", "W", "O");

如果使用 var 的話,還可以再簡化:

var arrLt = Util.asList("B", "X", "A", "M", "F", "W", "O");

某些方法宣告下,編譯器無法從引數推斷型態參數的實際型態,那就可能從其他管道來進行推斷。例如,你可能如下定義:

...
public class BeanUtil {
    public static <T> T getBean(Map<String, Object> data, String clzName)
                                   throws Exception {
        Class clz = Class.forName(clzName);
        ...
        return (T) bean; 
    }
}

想使用這個程式片段中的 getBean 方法,完整語法可以如下:

Student student = BeanUtil.<Student>getBean(data, "cc.openhome.Student");

就以上片段來說,其實編譯器可以從 student 的型態推斷,型態參數 T 應該是 Student,可以簡化撰寫為:

Student student = BeanUtil.getBean(data, "cc.openhome.Student");

編譯器會自動推斷 T 代表的型態,就不用額外指定 <Student>,完整語法是想在鏈狀操作時使用。例如:

String name = BeanUtil.<Student>getBean(
                     data, "cc.openhome.Student").getName();

在上例,如果沒有指定 <Student>,那麼就無法呼叫傳回的 Student 物件 getName 方法,因為編譯器會將 getBean 傳回的物件型態當作 Object 處理,而 Object 並沒有 getName 方法,因而發生錯誤,這跟上面的 UtilasList 可以比較一下:

Util.asList("B", "X", "A", "M", "F", "W", "O").get(10).toUpperCase();

這個語法不會發生錯誤,因為編譯器可以從 asList 的引數,瞭解到型態參數 T 實際上是 String 型態,因而傳回型態會是 ArrayList<String>,而呼叫 get 會傳回 String,因而最後可以呼叫 toUpperCase

分享到 LinkedIn 分享到 Facebook 分享到 Twitter