1. 介紹
想像你有一個萬用的盒子,裡面可以放任何東西:蘋果、書,甚至玩具。在 Java 中,這個「萬用盒子」就是一個把資料儲存為最通用型別 Object 的類別。這個型別是 Java 中所有其他類別的父類別,因此在 Object 型別的變數中可以放入任何物件。
再想像一下有個裝滿這種盒子的倉庫。如果盒子上沒有標籤,你可以把任何東西放進去,但當需要取出某樣東西時——就得打開盒子猜裡面是什麼。而使用泛型的情況就像是倉庫裡的盒子都有清楚的標示:「只放蘋果」、「只放書」、「只放工具」。你總是知道每個盒子裡裝的是什麼,也不會不小心把書放進裝蘋果的盒子。
乍看之下,把東西都存成 Object 似乎很方便:不需要為不同資料型別建立不同的類別。但實務上,這種「通用性」會帶來問題:
- 很容易犯錯。 你可能不小心把不是預期的物件放進盒子。
- 編譯器不會發現問題。 它會允許你放任何東西,因為 Object 型別允許這麼做。
- 必須手動「拆箱」。 當你從這種盒子取出物件時,它會是 Object 型別,你得自己把它轉為需要的型別(這稱為型別轉換或 cast)。如果轉錯型別,程式就會直接因錯誤而停止執行!
讓我們用一個簡單的例子看看。
class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
現在來使用這個盒子:
Box box = new Box();
box.set("你好"); // 放入字串
String s = (String) box.get(); // 取出字串,一切正常
box.set(123); // 放入數字
// 編譯器看不出問題...
String t = (String) box.get(); // 程式在執行時發生錯誤!
如你所見,編譯器輕鬆地放過了最終導致錯誤的程式碼。我們直到程式執行並「當掉」時才知道出問題。
2. 解法 — 泛型
Generics(泛型)是解決這個問題的方法。它是一種特殊語法,讓你能在編譯期就把類別或方法綁定到特定資料型別。更簡單地說,這就像在盒子上貼標籤:「這個盒子裡只能放字串」或「這個盒子裡只能放數字」。
如此一來,我們就能獲得型別安全:編譯器不會讓你把錯誤的物件放進「盒子」。它會檢查你的程式碼,在執行前就指出錯誤。
同樣的 Box 類別,但現在使用泛型:
class Box<Type> {
private Type value;
public void set(Type value) {
this.value = value;
}
public Type get() {
return value;
}
}
這裡的 Type 是型別參數。這是我們自己選擇的名稱(通常使用 T、E、K、V),它表示:「當我們建立 Box 時,會指定它要處理的型別,而我會在程式中所有寫了 Type 的地方使用該型別。」
3. 使用泛型
當我們建立帶泛型的類別實例時,會在尖括號 <...> 中指定具體的型別。
// 建立只處理字串的盒子
Box<String> stringBox = new Box<>();
stringBox.set("你好,世界!"); // OK,放入字串
String s = stringBox.get(); // 取出字串,無需轉型
stringBox.set(123); // 編譯錯誤!編譯器不會允許這麼做。
如果我們建立 Box<Integer>,編譯器會確保只能放數字:
// 建立只處理數字的盒子
Box<Integer> intBox = new Box<>();
intBox.set(42); // OK,放入數字
Integer number = intBox.get(); // 取出數字,無需轉型
intBox.set("你好"); // 編譯錯誤!
現在編譯器確切知道每個盒子中應該是什麼型別,並可靠地保護我們避免出錯。
4. 透過範例看運作方式
泛型不僅可用於類別,也可用於方法。這能讓我們撰寫非常彈性且通用的程式碼。
範例 1 — 帶泛型的類別
假設我們需要一個 Pair 類別,用來儲存兩個相同型別的物件。使用泛型如下:
class Pair<T> {
private T first;
private T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
使用方式:
Pair<String> greetings = new Pair<String>("你好", "世界");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());
Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());
在第一種情況,編譯器能確信 greetings 裡是字串;第二種情況則是數字。
範例 2 — 泛型方法
我們可以撰寫能處理任何型別資料的方法。
class Utils {
// <T> 在 void 之前表示該方法會使用型別參數 T
public static <T> void printTwice(T value) {
System.out.println(value);
System.out.println(value);
}
}
現在我們可以用任何型別來呼叫這個方法,它都會以相同方式運作:
Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);
5. 泛型的優點
泛型不只是語法糖,而是能解決實際開發問題的強大工具。讓我們來看看三個關鍵優點。
型別安全是泛型最重要的優點。沒有泛型時,編譯器無法檢查你是否在「萬用」的類別與方法中正確使用了資料型別。像是把字串放進「只裝數字的盒子」這樣的錯誤,只有在程式執行期間才會被發現,並以 ClassCastException 異常讓程式突然「當掉」。使用泛型後,編譯器會嚴格地追蹤型別:它會檢查你是否只把字串放進 Box<String>,只把數字放進 Box<Integer>。如果你嘗試做錯的事,它會立刻指出錯誤,讓你在執行前就能修正。這使你的程式碼更加可靠且可預測。
程式碼更乾淨(不需要多餘的轉型)。回想我們沒有使用泛型的「萬用」盒子時的程式碼:Box box = new Box(); box.set("你好"); String s = (String) box.get(); 每次從盒子取出物件時,你都得寫上 (String)、(Integer) 等等。有了泛型就不必如此。編譯器已經知道裡面放的是什麼型別,會自動幫你處理:Box<String> stringBox = new Box<>(); stringBox.set("你好"); String s = stringBox.get(); 這不只讓程式碼更短,也大幅提升可讀性與易用性。
彈性與程式碼可重用性。泛型讓你能建立真正通用的類別與方法,能處理不同的資料型別,同時不犧牲型別安全。例如,Box<T> 可以用來存放字串、數字、你自己的類別(如 Student、Car)——幾乎任何東西!你不需要分別撰寫 StringBox、IntegerBox、StudentBox。你只要寫一個通用的 Box<T> 類別,然後在建立物件時指定所需的型別即可。這能減少程式碼、避免重複,並讓你的程式更具模組化與彈性。
6. 泛型的限制
儘管有以上優點,泛型仍有幾個重要限制需要了解。
基本型別(primitives)。你不能使用基本型別(如 int、double、boolean 等)作為型別參數。比方說,Box<int> intBox = new Box<>(); 會造成編譯錯誤。必須改用它們的「包裝類別」(Integer、Double、Boolean)。Box<Integer> intBox = new Box<>();。Java 編譯器能在基本型別與包裝類別之間自動轉換(int 與 Integer 之間),這個機制稱為自動裝箱/拆箱(autoboxing/unboxing)。
型別擦除(Type Erasure)。這是 Java 中泛型實作的關鍵特性。其概念是,Java 編譯器只在編譯期間使用泛型資訊。當它檢查完你的程式碼並確認其型別安全之後,就會擦除所有型別參數的資訊。舉例來說,Box<String> 與 Box<Integer> 在編譯後(位元組碼中)看起來都只是 Box。也就是說,對 Java 虛擬機(JVM)而言,它們是同一種型別。
這對你意味著什麼?你不能建立泛型陣列,例如 new Box<String>[10],也不能用 instanceof 與泛型一起來檢查某物件是否為 instanceof Box<String>。這是較進階的主題,但對深入學習 Java 很重要。此刻只要記住:泛型本質上是給編譯器用的「標籤」,幫助它在編譯時檢查程式;當程式準備執行時,這個標籤就會被拿掉。
GO TO FULL VERSION