Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

泛型與標準庫

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 泛型與標準庫:把型別錯誤擋在編譯期〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。