學完本章你將能夠:
說明泛型要解決的問題:把型別錯誤從執行期提前到編譯期。
自行設計泛型類別(單一與多個型別參數)與泛型方法,並遵循命名慣例。
用有界型別參數(extends)限制型別,寫出既通用又安全的演算法。
分辨三種萬用字元的適用時機,並以 PECS 原則設計方法簽章。
解釋泛型的不變性、陣列的協變性,以及型別抹除帶來的各種限制。
實作一個型別安全的泛型資料結構。
上一章你已經在使用泛型:|List<String>| 保證清單裡只有字串。本章換到另一邊,學會設計泛型——寫出像 |ArrayList<E>| 那樣「一份程式碼、服務所有型別」的類別與方法。泛型是 Java 5 引入的編譯期機制,核心理念只有一句話:讓編譯器在寫程式的當下就抓到型別錯誤,而不是讓使用者在執行期看到程式爆炸。
理解泛型的最好起點,是回到沒有泛型的年代,看看工程師們踩過什麼坑。
沒有泛型的年代
Java 5 之前,集合的元素型別一律是 |Object|:什麼都能放,取出來卻只知道是 |Object|,必須自己強制轉型。編譯器對「放錯東西」完全沉默,錯誤直到執行期才引爆:
import java.util.ArrayList; import java.util.List;
/** raw type 的執行期炸彈:編譯器閉嘴,執行期爆炸 */ public class RawTypeDanger
public static void main(String[] args)
// raw type:不帶型別參數的集合,什麼都能放 List names = new ArrayList(); names.add("小明"); names.add(42); // 整數也放得進去,編譯器只給警告
for (Object obj : names) // 取出時必須自己轉型:輪到 42 的時候當場爆炸 String name = (String) obj; System.out.println("名字長度:" + name.length());
名字長度:2 Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader ’bootstrap’) at RawTypeDanger.main(RawTypeDanger.java:14)
問題的根源:「這個清單只放字串」的約定只存在於工程師的腦中,編譯器無從得知。泛型就是把這個約定寫進型別系統——宣告 |List<String>| 之後,|add(42)| 直接編譯失敗,取出時也不再需要轉型。錯誤發生的時間點從「使用者操作到那一行的深夜」提前到「你寫下那一行的當下」,這就是泛型的全部價值。
不帶型別參數的寫法(|List|、|ArrayList|)稱為 raw type,僅為了與老程式碼相容而存在。新程式碼永遠不要使用 raw type——編譯器發出的「unchecked」警告,就是在提醒你型別安全已經破功。
泛型類別
在類別名稱後面加上角括號與型別參數,這個類別就成了泛型類別。型別參數是「型別的佔位符」,等到使用端宣告時才填入具體型別。命名有業界慣例:|T|(Type,一般型別)、|E|(Element,集合元素)、|K| 與 |V|(Key 與 Value,鍵與值)、|R|(Result,回傳值):
/** * 泛型類別 Box:型別參數 T 是「型別的佔位符」, * 等到使用的那一刻才決定 T 是什麼。 */ class Box<T>
private T content; // 欄位的型別用型別參數表示
/** 放入內容 */ public void put(T content) this.content = content;
/** 取出內容:回傳型別就是 T,呼叫端完全不必轉型 */ public T get() return content;
/** Pair:兩個型別參數(K 與 V)的泛型類別 */ class Pair<K, V>
private final K key; private final V value;
public Pair(K key, V value) this.key = key; this.value = value;
public K getKey() return key;
public V getValue() return value;
@Override public String toString() return "(" + key + ", " + value + ")";
/** 使用泛型類別 */ public class BoxDemo
public static void main(String[] args)
// T 指定為 String:put 只收 String,get 直接回傳 String Box<String> gift = new Box<>(); // 菱形運算子:右側型別可省略 gift.put("音樂盒"); String content = gift.get(); // 不需要 (String) 轉型 System.out.println("盒子裡是:" + content);
// gift.put(42); // 編譯錯誤:42 不是 String,錯誤提前現形
// K 是 String、V 是 Integer Pair<String, Integer> entry = new Pair<>("身高", 175); System.out.println(entry.getKey() + ":" + entry.getValue()); System.out.println("完整輸出:" + entry);
盒子裡是:音樂盒 身高:175 完整輸出:(身高, 175)
對照 |RawTypeDanger|:同樣是「放進去、拿出來」,|Box<String>| 讓錯誤的 |put(42)| 在編譯期就被攔下,正確的 |get()| 則不需要任何轉型。型別檢查與轉型的苦工全部由編譯器代勞。
泛型方法
型別參數也可以只屬於單一方法:宣告在回傳型別之前的角括號中。泛型方法不需要類別本身是泛型的,static 方法也適用。多數情況下編譯器能從引數推斷出型別參數,呼叫端寫起來與一般方法無異:
import java.util.List;
/** 泛型方法:型別參數宣告在回傳型別「之前」 */ public class GenericMethodDemo
/** 交換陣列中兩個位置的元素:對任何參考型別都適用 */ static <T> void swap(T[] array, int i, int j) T tmp = array[i]; // 型別參數讓暫存變數的型別正確 array[i] = array[j]; array[j] = tmp;
/** 回傳清單的第一個元素:回傳型別跟著傳入的清單走 */ static <T> T first(List<T> list) return list.get(0);
public static void main(String[] args)
String[] words = "甲", "乙", "丙" ; swap(words, 0, 2); // 型別推斷:T 自動推為 String System.out.println(String.join("", words));
Integer[] nums = 1, 2, 3 ; // 也可以明確指定型別參數(通常不必,編譯器推得出來) GenericMethodDemo.<Integer>swap(nums, 0, 1); System.out.println(nums[0] + ", " + nums[1] + ", " + nums[2]);
List<Double> prices = List.of(19.99, 5.5); double p = first(prices); // T 推斷為 Double,再自動拆箱 System.out.println("第一個價格:" + p);
丙乙甲 2, 1, 3 第一個價格:19.99
有界型別參數
「任何型別都可以」有時太寬鬆。想寫一個找最大值的方法,方法內必須呼叫 |compareTo|——那 T 就不能是隨便的型別,必須「會比較」。用 |extends| 設定上界(介面也用 extends,不用 implements):
/** 有界型別參數:T 必須「會比較大小」才能找最大值 */ public class BoundedTypeDemo
/** * 回傳三者中的最大值。 * 界限「extends Comparable」保證 T 一定實作了 Comparable, * 否則方法本體根本無法呼叫 compareTo。 */ static <T extends Comparable<T>> T max(T a, T b, T c) T result = a; // 先假設 a 最大 if (b.compareTo(result) > 0) // 界限保證 compareTo 存在 result = b; if (c.compareTo(result) > 0) result = c; return result;
public static void main(String[] args) // Integer 與 String 都實作了 Comparable,直接可用 System.out.println("最大整數:" + max(3, 9, 5)); System.out.println("最大字串:" + max("pear", "apple", "kiwi")); // max(new Object(), ...):編譯錯誤,Object 沒實作 Comparable
最大整數:9 最大字串:pear
界限是雙向的承諾:對方法本體,它保證 |compareTo| 一定存在,程式才編得過;對呼叫端,它擋掉不具備能力的型別。界限可以不只一個——|<T extends A & B>| 表示 T 必須同時滿足 A 與 B(最多一個類別且必須寫在最前面,其餘為介面),例如 !<T extends Animal & Comparable<T>>!。
萬用字元
型別參數解決「定義泛型」的問題,萬用字元(wildcard)|?| 則解決「接收各種泛型」的問題,共三種形式:無界 |<?>|(任何型別的清單都收)、上界 !<? extends T>!(T 或其子型別的清單)、下界 !<? super T>!(T 或其父型別的清單):
import java.util.ArrayList; import java.util.List;
/** 萬用字元三兄弟:無界 ?、上界 ? extends、下界 ? super */ public class WildcardDemo
/** 無界萬用字元:任何 List 都能收,但元素只能當 Object 讀 */ static void printAll(List<?> list) for (Object item : list) // 只知道它「是個物件」 System.out.print(item + " "); System.out.println();
/** 上界:收 Number 或其子型別的清單——只能讀(生產者) */ static double sum(List<? extends Number> list) double total = 0; for (Number n : list) // 讀出來保證是 Number total += n.doubleValue(); // list.add(1); // 編譯錯誤:實際型別未知,寫入不安全 return total;
/** 下界:收 Integer 或其父型別的清單——可以安全寫入(消費者) */ static void fillCount(List<? super Integer> list, int n) for (int i = 1; i <= n; i++) list.add(i); // 寫入 Integer 一定相容
public static void main(String[] args)
List<Integer> ints = List.of(1, 2, 3); List<Double> doubles = List.of(0.5, 1.5);
printAll(ints); // List<?> 什麼清單都能印 printAll(doubles);
// 上界:Integer、Double 的清單都可以傳進 sum System.out.println("整數和:" + sum(ints)); System.out.println("小數和:" + sum(doubles));
// 下界:List<Number>(Integer 的父型別清單)可以傳入 List<Number> numbers = new ArrayList<>(); fillCount(numbers, 3); System.out.println("寫入結果:" + numbers);
1 2 3 0.5 1.5 整數和:6.0 小數和:2.0 寫入結果:[1, 2, 3]
為什麼「上界只能讀、不能寫」?!List<? extends Number>! 的實際型別可能是 |List<Integer>| 也可能是 |List<Double>|——編譯器無法確定。讀是安全的:不管哪一種,取出來一定「是一種 Number」。寫則不安全:往可能是 |List<Double>| 的清單裡放 |Integer| 顯然錯誤,既然無法排除這個可能,編譯器乾脆禁止一切寫入(|null| 除外)。反過來,!List<? super Integer>! 的實際型別是 |List<Integer>|、|List<Number>| 或 |List<Object>|——不管哪一種,「放進一個 Integer」都合法,所以寫入 Integer 安全;但讀出來只能當 |Object|,因為元素實際型別無法保證。
PECS 原則
把上面的推理濃縮成一條設計口訣,就是著名的 PECS:Producer Extends, Consumer Super——資料的生產者(你從它讀)用 |extends|,資料的消費者(你往它寫)用 |super|。標準函式庫的複製方法正是教科書級示範:
/** 把來源清單的元素全部複製到目的清單 */ static <T> void copy(List<? super T> dst, List<? extends T> src) for (T item : src) // src 是生產者:從它讀出 T(extends) dst.add(item); // dst 是消費者:把 T 寫進它(super)
// 呼叫範例:來源是 List<Integer>,目的地可以是 List<Number> // List<Number> dst = new ArrayList<>(); // copy(dst, List.of(1, 2, 3)); // T 推斷為 Integer
設計方法簽章時的操作步驟:先問「這個參數對方法而言是讀進來還是寫出去」。只讀用 !? extends!、只寫用 !? super!、又讀又寫就只能用精確型別 |List<T>|。遵循 PECS 的簽章能接受的引數範圍最大,這正是 |Collections.copy| 等標準方法的實際寫法。
泛型與繼承:不變性
|Integer| 是 |Number| 的子類別,但 |List<Integer>| 不是 |List<Number>| 的子型別——泛型是不變的(invariant)。對照組是陣列:|Integer[]| 可以指派給 |Number[]|(協變,covariant)。陣列的寬容其實是歷史包袱,它把型別錯誤延後到執行期:
/** 陣列是協變的:編譯器放行,執行期才發現放錯東西 */ public class CovarianceDemo
public static void main(String[] args)
// 陣列:Integer[] 可以指派給 Number[](協變) Number[] numbers = new Integer[2]; numbers[0] = 1; // OK:放 Integer try numbers[1] = 3.14; // 編譯過關,執行期才被抓包 catch (ArrayStoreException e) System.out.println("執行期例外:" + e);
// 泛型:List<Integer> 不能指派給 List<Number>(不變性) // List<Number> list = new ArrayList<Integer>(); // 編譯錯誤 // 這正是泛型的初衷:把錯誤攔在編譯期,而不是執行期 System.out.println("陣列內容:" + numbers[0] + ", " + numbers[1]);
執行期例外:java.lang.ArrayStoreException: java.lang.Double 陣列內容:1, null
|numbers| 的宣告型別是 |Number[]|,放 |3.14| 在編譯器眼中完全合法;但它實際指向 |Integer[]|,JVM 只能在執行期用 |ArrayStoreException| 補救——注意輸出第二行,該格子還停留在 |null|。泛型拒絕協變,正是拒絕把這種錯誤留到執行期;需要「一個方法收多種元素型別的清單」時,正解不是協變,而是上一節的萬用字元。
型別抹除
Java 的泛型靠型別抹除(type erasure)實作:型別參數只活在編譯期,編譯完成後被抹除——|Box<T>| 的 T 換成 |Object|(有界限則換成界限型別),需要之處自動補上轉型。好處是與 Java 5 之前的位元組碼完全相容;代價是執行期「看不到」型別參數:
import java.util.ArrayList; import java.util.List;
/** 型別抹除的可觀察證據 */ public class ErasureDemo
public static void main(String[] args)
List<String> strings = new ArrayList<>(); List<Integer> integers = new ArrayList<>();
// 執行期兩者是同一個類別物件:型別參數已被抹除 System.out.println(strings.getClass() == integers.getClass()); System.out.println(strings.getClass().getName());
true java.util.ArrayList
型別抹除解釋了泛型的所有「不能」:不能 |new T()|(執行期不知道 T 是誰,生不出物件);不能建立泛型陣列 |new T[10]|(陣列必須在執行期知道元素型別以便檢查協變寫入,抹除後無從檢查);不能 !obj instanceof List<String>!(執行期只剩 |List|,角括號內容早已消失,只能寫 !obj instanceof List<?>!);static 成員不能使用類別的型別參數(static 成員屬於類別,而抹除後所有參數化版本共用同一個類別)。遇到這些限制時,繞法通常是改用 |List<T>| 取代陣列,或把 |Class<T>| 物件當參數傳入。
綜合範例:型別安全的泛型堆疊
最後把本章知識落地成一個實用的資料結構:堆疊(stack),後進先出(LIFO)。內部用 |ArrayList| 儲存(組合優於繼承,第 1 章),對外只暴露堆疊該有的四個操作:
import java.util.ArrayList; import java.util.List;
/** * 型別安全的泛型堆疊(LIFO:後進先出)。 * 內部用 ArrayList 保存元素,尾端視為堆疊頂端。 */ class GenericStack<E>
// 組合而非繼承:內部細節不外洩,公開介面完全由我們決定 private final List<E> elements = new ArrayList<>();
/** 推入:把元素放上堆疊頂端 */ public void push(E element) elements.add(element); // 加到尾端就是頂端
/** 彈出:移除並回傳頂端元素 */ public E pop() if (isEmpty()) // 空堆疊還要彈出是呼叫端的錯:拋 unchecked 例外 throw new IllegalStateException("堆疊是空的"); return elements.remove(elements.size() - 1);
/** 偷看:回傳頂端元素但不移除 */ public E peek() if (isEmpty()) throw new IllegalStateException("堆疊是空的"); return elements.get(elements.size() - 1);
/** 是否為空 */ public boolean isEmpty() return elements.isEmpty();
/** 目前元素個數 */ public int size() return elements.size();
設計筆記:選擇「包一個 |List|」而不是「繼承 |ArrayList|」,是因為繼承會把 |get|、|add(index, e)| 等六十多個方法全部曝露出去,任何人都能從中間戳破堆疊的紀律;組合讓公開介面完全由我們決定——這正是第 1 章「繼承與組合」討論的實戰版。測試一下:
/** 測試泛型堆疊:同一份程式碼服務所有元素型別 */ public class GenericStackDemo
public static void main(String[] args)
// 模擬瀏覽器的「上一頁」歷史堆疊 GenericStack<String> history = new GenericStack<>(); history.push("首頁"); history.push("商品列表"); history.push("商品明細");
System.out.println("目前頁面:" + history.peek()); System.out.println("頁面數量:" + history.size());
// 連續按兩次上一頁 System.out.println("返回:" + history.pop()); System.out.println("返回:" + history.pop()); System.out.println("目前頁面:" + history.peek());
// 換一種元素型別:不需要改動 GenericStack 的任何一行 GenericStack<Integer> numbers = new GenericStack<>(); numbers.push(10); numbers.push(20); System.out.println("整數堆疊頂端:" + numbers.pop()); System.out.println("清空了嗎:" + numbers.isEmpty());
目前頁面:商品明細 頁面數量:3 返回:商品明細 返回:商品列表 目前頁面:首頁 整數堆疊頂端:20 清空了嗎:false
同一份 |GenericStack| 原始碼,前半段裝字串、後半段裝整數,兩邊都享有完整的編譯期型別檢查——這就是泛型的承諾:一次撰寫,處處型別安全。
本章小結
泛型的使命:把「放錯型別」的錯誤從執行期的 |ClassCastException| 提前為編譯期錯誤;raw type 僅為相容而存在,新程式碼禁用。
泛型類別在類別名後宣告型別參數;命名慣例 |T|、|E|、|K|、|V|、|R|。
泛型方法的型別參數宣告在回傳型別之前;編譯器通常能自動推斷,也可用 |類別.<型別>方法()| 明確指定。
有界型別參數 |<T extends 界限>| 對內保證能力可用、對外擋掉不合格型別;多重界限用 |&| 串接。
萬用字元:|<?>| 只讀不寫;!<? extends T>! 讀出保證是 T(生產者);!<? super T>! 寫入 T 安全(消費者)。
PECS 口訣:Producer Extends, Consumer Super;又讀又寫用精確型別。
泛型不變、陣列協變:陣列把錯誤留給執行期的 |ArrayStoreException|,泛型在編譯期就拒絕。
型別抹除:型別參數編譯後消失,因此不能 |new T()|、不能建泛型陣列、|instanceof| 不能帶型別參數、static 成員用不到型別參數。
設計泛型容器時優先用組合包住既有集合,只暴露該有的操作。
本章練習
觀念題:為什麼 |List<String>| 不能指派給 |List<Object>|,但 |String[]| 可以指派給 |Object[]|?兩種設計各把錯誤攔在什麼時機?(提示:回想 |ArrayStoreException| 範例。)
實作題:寫一個泛型方法 !<T> int countMatches(List<T> list, T target)!,回傳清單中與目標 |equals| 相等的元素個數,並分別以字串清單與整數清單測試。(提示:泛型方法加 for-each。)
實作題:寫一個泛型方法 !<T extends Comparable<T>> T min(List<T> list)!,回傳清單最小值;清單為空時拋出 |IllegalArgumentException|。(提示:模仿本章的 |max| 三值版本改成走訪版本。)
觀念題:方法簽章 !void addAll(List<? extends Number> dst)! 哪裡設計錯了?依 PECS 應該怎麼改?(提示:dst 是被寫入的一方,它是生產者還是消費者?)
實作題:為 |GenericStack| 新增方法 !void pushAll(List<? extends E> items)!(把清單全部推入)與 !void popAll(List<? super E> dst)!(全部彈出並放入目的清單),體會 PECS 在真實 API 的樣貌。(提示:兩個萬用字元各用一次。)
挑戰題:實作泛型類別 |Pair<K, V>| 的靜態工廠方法 !static <K, V> Pair<K, V> of(K key, V value)!,並解釋為什麼 static 方法必須自己宣告型別參數、不能借用類別的 K 與 V。(提示:型別抹除後 static 成員屬於誰?)