Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

泛型與標準庫

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 標準庫如何大量倚賴這套機制。

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 特有的細節:

  1. 不需要強制轉型nameBox.get() 直接就是 String。在沒有泛型的年代,你得寫 (String) box.get(),既囉嗦又容易寫錯。
  2. 型別參數不能是基本型別(primitive type)。你不能寫 Box<int>,只能寫 Box<Integer>。這是因為泛型在 Java 裡只對「物件參考型別」(reference type)生效——這個限制的根源(型別抹除)我們留到最後一節解釋。intInteger 之間的自動轉換稱為自動裝箱/拆箱(autoboxing/unboxing),是 Java 替你補上的便利。

常用的型別參數命名慣例:T(Type)、E(Element,集合常用)、KV(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.sortArrays.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 的型別」。因為 IntegerString 都實作了 Comparable,所以它們都能套用;而像自訂的、沒實作比較介面的類別則會被編譯器擋下。這是泛型「型別安全」的另一個面向:不只限制你放什麼進去,還能保證 T 一定有某些方法可呼叫。

萬用字元:當你只在乎「讀」或「寫」

考慮一個方法,想印出任何清單的內容:

// 直覺寫法——但它其實非常受限
static void printAll(List<Object> list) {
    for (Object o : list) System.out.println(o);
}

你可能以為 List<Integer> 能傳給 List<Object>,畢竟 IntegerObject。在 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 Numbersum 同時接受 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 日常的縮影。

常見錯誤

初學泛型與標準庫,這幾個雷幾乎人人踩過:

  1. 用原生型別(raw type)List 而非 List<T>List list = new ArrayList() 編譯器只會給警告不會報錯,但它放棄了所有型別檢查,等於把自己丟回 ClassCastException 的火坑。永遠寫上 <>

  2. 以為 List<Integer>List<Object> 的子型別。它不是。需要「接受多種元素型別」時用萬用字元 List<? extends Number>,而不是把參數型別寫成 List<Object>

  3. ? extends 的集合上呼叫 addList<? extends Number> 是唯讀的生產者,編譯器不讓你寫入。記住 PECS:要寫就用 ? super

  4. 迴圈內用 String + 拼接。功能正確但效能是 $O(n^2)$。大量拼接改用 StringBuilder

  5. 對未排序陣列用 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 與動態語言、與其他靜態語言最根本的氣質差異。下次當你寫下一個尖括號 <>,你會知道:那不只是語法糖,而是一整套在編譯期就替你把關、在執行期悄然退場的型別契約。

AI 共讀助教正在陪你讀:Java 泛型與標準庫:把型別錯誤擋在編譯期
嗨!我是這篇文章的共讀助教,只根據〈Java 泛型與標準庫:把型別錯誤擋在編譯期〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。