Java 泛型與標準庫:把型別錯誤擋在編譯期
從一個會在執行期爆炸的清單出發,看泛型如何用編譯期型別檢查換來執行期安全,並巡禮 String、Math、Arrays、StringBuilder 四把日常利器,最後深入型別抹除的設計取捨。
從一個會在執行期爆炸的清單說起
假設你正在寫一個成績管理小工具,需要一個「裝整數的清單」。在還沒接觸泛型(generics)之前,Java 的清單長這樣:
import java.util.ArrayList;
import java.util.List;
List scores = new ArrayList(); // 沒有型別參數的「原生型別」(raw type)
scores.add(90);
scores.add(85);
scores.add("作弊"); // 編譯器完全不阻止你!
int total = 0;
for (Object o : scores) {
total += (Integer) o; // 跑到 "作弊" 這筆就 ClassCastException
}
這段程式碼可以編譯成功,但執行到第三筆資料時會丟出 ClassCastException。問題在於:你的腦袋知道這個清單「應該」只放整數,但編譯器不知道。錯誤被推遲到了執行期(runtime),而且是在使用者面前才爆炸。
相較於 Python——它是動態型別,本來就接受任何東西進清單,靠的是「執行時你自己小心」——Java 是靜態強型別語言:它的設計哲學是「能在編譯期抓到的錯,絕不留到執行期」。泛型正是 Java 把這個承諾兌現的工具。讓我們把上面那段改寫:
List<Integer> scores = new ArrayList<>(); // 鑽石運算子 <> 自動推導型別
scores.add(90);
scores.add(85);
// scores.add("作弊"); // 編譯錯誤!第一時間就被擋下,連跑都不用跑
現在 scores.add("作弊") 會在你按下編譯的那一刻就紅字報錯。這就是本文的主軸:泛型如何用編譯期的型別檢查,換來執行期的安全,以及 Java 標準庫如何大量倚賴這套機制。

泛型類別:把「型別」當參數傳進去
泛型的核心想法很單純:讓一個類別在「裝什麼型別」這件事上保持彈性,但又在每個具體使用點上鎖死型別。我們自己寫一個簡單的「盒子」來體會:
// T 是「型別參數」(type parameter),慣例用單一大寫字母
public class Box<T> {
private T content;
public void put(T item) {
this.content = item;
}
public T get() {
return content;
}
}
使用時,T 會被你指定的具體型別取代:
Box<String> nameBox = new Box<>();
nameBox.put("Uedu");
String name = nameBox.get(); // 不需要強制轉型,編譯器知道它是 String
// nameBox.put(42); // 編譯錯誤:42 不是 String
Box<Integer> ageBox = new Box<>();
ageBox.put(20);
int age = ageBox.get(); // 自動拆箱 (unboxing) 成 int
注意兩個 Java 特有的細節:
- 不需要強制轉型。
nameBox.get()直接就是String。在沒有泛型的年代,你得寫(String) box.get(),既囉嗦又容易寫錯。 - 型別參數不能是基本型別(primitive type)。你不能寫
Box<int>,只能寫Box<Integer>。這是因為泛型在 Java 裡只對「物件參考型別」(reference type)生效——這個限制的根源(型別抹除)我們留到最後一節解釋。int與Integer之間的自動轉換稱為自動裝箱/拆箱(autoboxing/unboxing),是 Java 替你補上的便利。
常用的型別參數命名慣例:T(Type)、E(Element,集合常用)、K/V(Key/Value,Map 常用)、R(Return)。這只是慣例,但遵守它能讓讀你程式碼的人秒懂意圖。
泛型方法:型別參數也能掛在單一方法上
不只類別,單一方法也可以有自己的型別參數。語法是把 <T> 放在回傳型別之前:
public class Utils {
// <T> 宣告在回傳型別前面,代表這是泛型方法
public static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
// 多個型別參數也沒問題
public static <K, V> void describe(K key, V value) {
System.out.println(key + " -> " + value + " (" + value.getClass().getSimpleName() + ")");
}
}
呼叫時,編譯器通常能自動推導型別,你不必明寫:
List<String> words = List.of("alpha", "beta");
String w = Utils.firstOrNull(words); // T 被推導為 String
Utils.describe("score", 95); // K=String, V=Integer
// 輸出:score -> 95 (Integer)
這裡值得一提的 Java 慣例:static 泛型方法非常常見,因為它不依賴任何實例狀態,純粹是「輸入什麼型別、輸出什麼型別」的對應。標準庫裡 Collections.sort、Arrays.asList 全是這種寫法。
有界型別參數:限制 T 的能力範圍
有時候你需要 T 具備某些能力。例如要找清單中的最大值,T 至少得「可以互相比較」。這時用 extends 設下界:
import java.util.List;
public class MathUtils {
// T 必須是 Comparable<T> 的子型別,才有 compareTo 可用
public static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T item : list) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
}
System.out.println(MathUtils.max(List.of(3, 7, 2))); // 輸出:7
System.out.println(MathUtils.max(List.of("pear", "apple"))); // 輸出:pear
<T extends Comparable<T>> 讀作「T 是任何實作了 Comparable 的型別」。因為 Integer、String 都實作了 Comparable,所以它們都能套用;而像自訂的、沒實作比較介面的類別則會被編譯器擋下。這是泛型「型別安全」的另一個面向:不只限制你放什麼進去,還能保證 T 一定有某些方法可呼叫。
萬用字元:當你只在乎「讀」或「寫」
考慮一個方法,想印出任何清單的內容:
// 直覺寫法——但它其實非常受限
static void printAll(List<Object> list) {
for (Object o : list) System.out.println(o);
}
你可能以為 List<Integer> 能傳給 List<Object>,畢竟 Integer 是 Object。錯。在 Java 裡,List<Integer> 不是 List<Object> 的子型別(這叫「泛型的不可變性」,invariance)。這個設計初看反直覺,但它防止了一個災難:如果允許,你就能透過 List<Object> 的參考往一個整數清單裡塞字串。
解法是萬用字元(wildcard)?:
// ? 代表「某個未知型別」,任何 List<X> 都能傳進來
static void printAll(List<?> list) {
for (Object o : list) System.out.println(o); // 讀出來一律當 Object
}
萬用字元有兩種帶界的形式,記憶口訣是 PECS(Producer Extends, Consumer Super):
import java.util.List;
public class Pipe {
// 上界 ? extends:這個 list 是「生產者」,只能讀(讀出來保證是 Number)
static double sum(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) total += n.doubleValue();
return total;
// nums.add(1); // 編譯錯誤!不能寫入:編譯器不知道實際元素型別
}
// 下界 ? super:這個 list 是「消費者」,只能寫(保證能塞 Integer 進去)
static void fillWithZeros(List<? super Integer> sink, int n) {
for (int i = 0; i < n; i++) sink.add(0);
// Integer x = sink.get(0); // 編譯錯誤!讀出來只能保證是 Object
}
}
List<Integer> ints = List.of(1, 2, 3);
List<Double> dbls = List.of(1.5, 2.5);
System.out.println(Pipe.sum(ints)); // 輸出:6.0
System.out.println(Pipe.sum(dbls)); // 輸出:4.0
? extends Number 讓 sum 同時接受 List<Integer> 與 List<Double>——只要元素是 Number 的子型別都行。代價是你不能往裡面寫,因為編譯器不確定實際型別是 Integer 還是 Double。? super Integer 反之:你能安全地塞 Integer 進去,但讀出來只能當 Object。
這套「讀/寫不對稱」的規則,是 Python 等動態語言完全不需要操心的——但也正因為 Java 在編譯期就把這些可能性窮舉清楚,你的程式才不會在執行期出現「往該唯讀的容器寫入」這類錯誤。
標準庫巡禮:每天都會用到的幾把刀
Java 的價值有一大半在於它龐大成熟的標準庫(standard library)。這是它「企業生態」優勢的基礎:開箱即用、跨平台、行為穩定。以下挑四個最高頻的。
String:不可變,所以安全
Java 的 String 是不可變(immutable)的。任何看似「修改」字串的方法,其實都回傳一個新字串:
String s = "Uedu 優學院";
System.out.println(s.length()); // 輸出:8(含中文,length 數的是 char 個數)
System.out.println(s.substring(0, 4)); // 輸出:Uedu
System.out.println(s.toUpperCase()); // 輸出:UEDU 優學院
System.out.println(s.replace("優", "好")); // 輸出:Uedu 好學院
System.out.println(s); // 輸出:Uedu 優學院(原字串完全沒變)
不可變性帶來一個重要後果:在迴圈裡用 + 拼接字串是反模式。因為每次 + 都產生一個新物件,$n$ 次拼接的總成本會退化到 $O(n^2)$。
StringBuilder:需要大量拼接時的正解
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 5; i++) {
sb.append(i).append(","); // append 回傳自己,可以串接呼叫 (method chaining)
}
sb.setLength(sb.length() - 1); // 去掉最後一個逗號
System.out.println(sb.toString()); // 輸出:1,2,3,4,5
StringBuilder 內部維護一個可變的字元陣列,append 攤提(amortized)成本是 $O(1)$,整段迴圈是 $O(n)$。規則很簡單:少量、固定次數的拼接用 +(可讀性好,編譯器還會幫你優化);迴圈內或不定次數的拼接一律用 StringBuilder。
Math:靜態數學工具
Math 整個類別都是 static 方法,不需要也不能 new:
System.out.println(Math.max(3, 9)); // 輸出:9
System.out.println(Math.abs(-7)); // 輸出:7
System.out.println(Math.pow(2, 10)); // 輸出:1024.0
System.out.println(Math.sqrt(144)); // 輸出:12.0
System.out.println(Math.round(3.6)); // 輸出:4
System.out.println((int) (Math.random() * 6) + 1); // 1~6 的隨機骰子
Arrays:陣列的瑞士刀
陣列本身在 Java 裡功能很陽春,Arrays 工具類補上了排序、搜尋、轉字串等:
import java.util.Arrays;
int[] nums = {5, 2, 8, 1, 9};
Arrays.sort(nums); // 原地排序,平均 O(n log n)
System.out.println(Arrays.toString(nums)); // 輸出:[1, 2, 5, 8, 9]
int idx = Arrays.binarySearch(nums, 8); // 須先排序才能用二分搜尋
System.out.println("8 在索引:" + idx); // 輸出:8 在索引:3
int[] copy = Arrays.copyOf(nums, 7); // 複製並補長度(不足補 0)
System.out.println(Arrays.toString(copy)); // 輸出:[1, 2, 5, 8, 9, 0, 0]
注意 Arrays.binarySearch 的前提是陣列已排序,否則結果未定義——這是初學者很常忽略的合約(contract)。
動手寫一段:泛型 + 標準庫的小整合
把上面所學湊成一個完整可執行的程式。它讀入一串學生分數,用泛型方法找出最高分,用 StringBuilder 組出報表,用 Math 算平均:
import java.util.List;
public class ScoreReport {
// 泛型方法:對任何可比較的型別找最大值
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T x : items) {
if (x.compareTo(best) > 0) best = x;
}
return best;
}
public static void main(String[] args) {
List<Integer> scores = List.of(72, 95, 88, 64, 90);
int sum = 0;
for (int s : scores) sum += s;
double avg = (double) sum / scores.size();
StringBuilder report = new StringBuilder();
report.append("===== 成績報表 =====\n");
report.append("人數:").append(scores.size()).append("\n");
report.append("最高分:").append(max(scores)).append("\n");
report.append("平均:").append(Math.round(avg * 10) / 10.0).append("\n");
System.out.print(report);
}
}
// 輸出:
// ===== 成績報表 =====
// 人數:5
// 最高分:95
// 平均:81.8
這支程式同時用上了:泛型方法(max)、有界型別參數(T extends Comparable<T>)、List 泛型集合、StringBuilder 高效拼接、Math.round 四捨五入。短短三十行,卻是 Java 日常的縮影。
常見錯誤
初學泛型與標準庫,這幾個雷幾乎人人踩過:
-
用原生型別(raw type)
List而非List<T>。List list = new ArrayList()編譯器只會給警告不會報錯,但它放棄了所有型別檢查,等於把自己丟回ClassCastException的火坑。永遠寫上<>。 -
以為
List<Integer>是List<Object>的子型別。它不是。需要「接受多種元素型別」時用萬用字元List<? extends Number>,而不是把參數型別寫成List<Object>。 -
在
? extends的集合上呼叫add。List<? extends Number>是唯讀的生產者,編譯器不讓你寫入。記住 PECS:要寫就用? super。 -
迴圈內用
String +拼接。功能正確但效能是 $O(n^2)$。大量拼接改用StringBuilder。 -
對未排序陣列用
Arrays.binarySearch。二分搜尋的前提是已排序,否則回傳值無意義。先Arrays.sort再搜尋。
深入探討(研究所視角)
到目前為止,泛型看起來像是「編譯器幫你做型別檢查」的純粹語言魔法。但它的實作方式——型別抹除(type erasure)——藏著 Java 一系列看似奇怪限制的根本原因,值得認真理解。
泛型只活在編譯期
Java 的泛型是編譯期的概念。當你的程式碼通過型別檢查後,編譯器會把所有型別參數「抹除」掉:Box<String> 與 Box<Integer> 在編譯成位元組碼(bytecode)後,都變回同一個 Box,內部的 T 一律被替換成它的上界(無界時就是 Object)。換句話說,JVM 在執行期根本不知道「這個 Box 裝的是 String」這回事。
Box<String> a = new Box<>();
Box<Integer> b = new Box<>();
System.out.println(a.getClass() == b.getClass()); // 輸出:true(執行期是同一個類別!)
為什麼 Java 當年(2004 年 Java 5 引入泛型時)選擇抹除而非保留?核心理由是向後相容:Java 5 之前已有海量使用原生型別的程式碼與 .class 檔,抹除讓帶泛型的新程式碼能與舊的 raw type 程式碼在同一個 JVM 上無痛共存,不必重新編譯整個生態系。這是務實的工程取捨——也是 Java「企業生態」優先於語言純粹性的典型寫照。
相較之下,C# 的泛型是具體化(reified)的,型別資訊在執行期完整保留;Python 則根本沒有編譯期型別檢查(型別標註只是給靜態工具與人看的提示)。Java 走的是介於兩者之間、以相容性為先的第三條路。
抹除帶來的具體限制
理解了抹除,下面這些「為什麼不行」就全都串起來了:
// 1. 不能對型別參數做 instanceof,因為執行期 T 已不存在
// if (x instanceof T) { ... } // 編譯錯誤
// 2. 不能 new 型別參數,因為 JVM 不知道該配置哪個類別
// T obj = new T(); // 編譯錯誤
// 3. 不能建立泛型陣列,因為陣列在執行期需要確切的元素型別
// T[] arr = new T[10]; // 編譯錯誤
// List<String>[] lists = new List<String>[5]; // 同樣不行
// 4. 基本型別不能當型別參數(這也是為何只能 Box<Integer> 不能 Box<int>)
// 抹除後 T 變成 Object,而 int 不是 Object 的子型別
第 4 點解釋了文章前面埋的伏筆:泛型只能用參考型別,因為抹除後一切退化成 Object,而基本型別不在物件繼承體系內。自動裝箱就是為了在這個縫隙上搭橋。
橋接方法與型別安全的邊界
抹除還會產生一個你平常看不見的副作用——橋接方法(bridge method)。當泛型類別被覆寫(override)時,編譯器為了讓多型(polymorphism)在抹除後仍正確運作,會偷偷合成一個型別為 Object 的橋接方法去呼叫你真正的方法。這通常不影響使用,但在用反射(reflection)檢視類別方法時會看到多出來的方法,了解它的存在能省下除錯時的困惑。
更實務的一點:因為執行期沒有型別資訊,泛型的型別安全是「只要你不繞過編譯器就成立」的保證。一旦你用原生型別、做未檢查的強制轉型,或透過反射硬塞,就可能製造出所謂的堆汙染(heap pollution)——一個宣告為 List<String> 的清單實際上混進了非字串元素,而錯誤要等到取用時才以 ClassCastException 現身。這也是為什麼編譯器對 @SuppressWarnings("unchecked") 這類標註如此謹慎:它在提醒你,這裡的型別安全是「你用人工保證的」,編譯器已經放手了。
理解了這層,你對 Java 泛型的認識就從「會用」進到了「知道它為什麼這樣設計、邊界在哪」。這種「語言機制服務於整個生態相容性」的取捨思維,正是 Java 與動態語言、與其他靜態語言最根本的氣質差異。下次當你寫下一個尖括號 <>,你會知道:那不只是語法糖,而是一整套在編譯期就替你把關、在執行期悄然退場的型別契約。