定義與使用泛型


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

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

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

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

以在 java.lang.Object 中實作過的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),高興的話,你可以用T、K、V等參數名稱。

由於使用<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,也就是回歸沒有使用泛型前的做法,例如:

...
ArrayList names = new ArrayList();
names.add("Justin");
names.add("Monica");
String name1 = (String) names.get(0);
String 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];
    }
...

使用這邊的ArrayList如下宣告時:

...
    ArrayList<String> words = new ArrayList<String>();
    words.add("one");
    String word = words.get(0);
...

實際上,泛型語法有一部份是編譯器蜜糖(一部份是記錄於位元碼中的資訊),若組譯以上程式片段,可以看到還是展開為JDK1.4之前的寫法:

...
    ArrayList arraylist = new ArrayList();
    arraylist.add("one");
    String s = (String)arraylist.get(0);
...

正因為展開後會有粗體字部份的語法,因而以下會編譯錯誤:

...
    ArrayList<String> words = new ArrayList<String>();
    words.add("one");
    Integer number = words.get(0); // 編譯錯誤
...

編譯器展開程式碼後,實際上會如下:

...
    ArrayList words = new ArrayList();
    words.add("one");
    Integer number = (String) words.get(0); // 編譯錯誤
...

若介面支援泛型,在實作時也會比較方便,例如想排序陣列的話,可以使用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的順序,這個寫法在JDK8支援Lambda之後,可以簡化為以下:

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

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

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

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

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

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

只要宣告參考時有指定型態,那麼建構物件時就不用再寫型態了,就語法簡潔度上不無小補。

適當地使用泛型語法,語法上可以簡潔一些,編譯器也可以事先作型態檢查,但泛型語法也可以用到很複雜(有人稱之為魔幻,這是個貼切的新形容詞),使用時以不影響可讀性與維護性為主要考量。