第 [sec:adt] 節的 用 當元素型別,「什麼都能放」——但代價是取出來時只剩 , 得自己轉型回去,而且編譯器無法阻止你把 塞進一個「應該只放 」的串列。 本節先把轉型講清楚,再介紹 Java 的解法:泛型,最後導覽建立在泛型之上的 Collections 框架。 隨附程式在 。
與轉型
Object x = "hello"; // 向上轉型(upcast):自動,永遠安全
Object y = 42; // 自動裝箱:int → Integer,再向上轉型
// x.length(); // 編譯錯誤:編譯器只知道 x 是 Object
String s = (String) x; // 向下轉型(downcast):「相信我,它其實是 String」
System.out.println(s.length()); // 5
Integer bad = (Integer) x; // 編譯器放行,執行期拋出 ClassCastException!
向上轉型(子型別 → 父型別)是自動且永遠安全的,因為每個 都是 。
向下轉型(父型別 → 子型別)要明寫 ,編譯器不檢查正確性, 而是在執行期確認物件的實際型別;不符就拋 。
轉型不會改變物件,只是換一副眼鏡看同一個東西。
安全的向下轉型要先問過 。Java 16 起的讓檢查與宣告變數一步完成:
if (y instanceof Integer n) { // 若 y 真的是 Integer,就把它綁定到變數 n
System.out.println(n + 1); // 43,不需要再寫 (Integer) y
}
程式裡到處出現 、,通常表示型別資訊在某處被丟掉了 (例如把東西放進 )。與其事後轉回來,不如一開始就讓編譯器記住型別——這正是泛型的用途。 合理的轉型只剩少數情況:實作 時、以及處理真正異質的資料。
泛型:把型別當參數
在類別或介面名稱後加上 , 就成為一個型別的佔位符,可以在該類別內當成型別使用。 使用時以型別引數填入: 是名字的名單, 是學號的名單。 同一份程式碼,對每一種型別都型別安全,且不需要轉型。
public interface GuestList<T> {
void add(T guest);
void remove(T guest);
boolean isComing(T guest);
int count();
T[] coming(); // ★ 這一行藏著一個陷阱,見 7.3
}
GuestList<String> names = new ArrayGuestList<>(); // <>(鑽石):讓編譯器推論型別引數
names.add("Fabian");
// names.add(42); // 編譯錯誤!42 不是 String——錯誤在編譯期就被抓到
String first = names.comingList().get(0); // 不需要 (String) 轉型
GuestList<Integer> ids = new ArrayGuestList<>();
ids.add(1001); // int 自動裝箱成 Integer
幾種常見的泛型寫法:
/** 泛型方法:<T> 寫在回傳型別前面;T 由編譯器從引數推論。 */
static <T> T firstOrDefault(List<T> list, T fallback) {
return list.isEmpty() ? fallback : list.get(0);
}
/** 有界型別參數:T 必須是 Comparable<T>,才保證能呼叫 compareTo。 */
static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T x : list) if (x.compareTo(best) > 0) best = x;
return best;
}
/** 兩個型別參數的泛型 record。 */
public record Pair<A, B>(A first, B second) {
public static <A, B> Pair<A, B> of(A first, B second) { return new Pair<>(first, second); }
public Pair<B, A> swap() { return new Pair<>(this.second, this.first); }
}
Pair<String, Integer> p = Pair.of("Liam", 30); // p.swap() 是 Pair<Integer, String>
型別參數用單一大寫字母:(type)、(element)、/(key/value)、/。 看到 就知道是「鍵型別 K、值型別 V」。
型別擦除:泛型在執行期不存在
Java 的泛型只存在於編譯期。編譯器用 檢查完型別後,就把它擦除(erase)成 , 產生的 bytecode 跟第 [sec:adt] 節的 版本幾乎一樣。這帶來幾個真實的限制:
不能 :執行期不知道 是誰。標準做法是內部用 ,取出時轉型:
private Object[] data = new Object[2]; // 不能是 T[]! @Override @SuppressWarnings("unchecked") // 我知道這個轉型沒被檢查,我負責它的安全 public T[] coming() { return (T[]) Arrays.copyOf(this.data, this.used); // 未受檢查的轉型(unchecked cast) }只要封裝保證「陣列裡只放過 」,這個轉型在實務上是安全的。
回傳值的陷阱:上面那個陣列在執行期真的是 ,所以:
Object[] ok = names.coming(); // OK String[] boom = names.coming(); // 編譯通過,執行期 ClassCastException!=== 4. 型別擦除的陷阱 === Object[] ok = [Fabian, Nicholas] String[] boom = names.coming() → ClassCastException! (T[] 在執行期被擦除成 Object[],這是 Java 泛型的真實限制) 正解 comingList() = [Fabian, Nicholas](用 List<T> 就沒有這個問題)編譯器在 這行偷偷插入了一個「檢查它是 」的指令,而它不是。 正解:泛型類別的方法回傳 ,不要回傳 ——標準函式庫也是這麼做的( 回傳 )。
型別引數不能是基本型別:沒有 ,只有 。 Java 會( → )與拆箱,大多時候無感,但有一個著名陷阱:
Integer a = 127, b = 127, c = 1000, d = 1000; a == b // true ——小整數(-128..127)被快取,碰巧是同一個物件 c == d // false!不同的 Integer 物件 c.equals(d) // true ——比較物件永遠用 equals
Java Collections 框架
自己寫 是為了理解原理;實務上一律使用 提供的、經過千錘百鍊的泛型集合。 整個框架就是一組介面(ADT)加上多種實作(資料結構)——本週所有觀念的集大成:
@lL0.27L0.42@ 介面 & 語意 & 實作與效能
& 有順序、可重複、用索引存取 & : \(\bigO{1}\),尾端 攤銷 \(\bigO{1}\),中間插刪 \(\bigO{n}\)(預設選擇)。 :兩端插刪 \(\bigO{1}\), \(\bigO{n}\)。
& 不重複、沒有索引 & :/ 平均 \(\bigO{1}\),順序不定。 :\(\bigO{\log n}\),走訪時由小到大。
& 鍵 → 值;鍵不重複 & :/ 平均 \(\bigO{1}\)。 :\(\bigO{\log n}\),依鍵排序。
/ & 先進先出/雙端 & :兩端 \(\bigO{1}\);也可當堆疊用。
List<String> fruits = new ArrayList<>(); // 宣告用介面 List,實作用 ArrayList
fruits.add("banana"); fruits.add("apple"); fruits.add("cherry"); fruits.add("apple");
System.out.println(fruits.get(1) + " " + fruits.contains("cherry")); // apple true
Collections.sort(fruits); // [apple, apple, banana, cherry]
fruits.remove("apple"); // 只移除第一個
for (String f : fruits) System.out.println(f); // for-each:走訪任何 Collection
Set<String> guests = new TreeSet<>(); // 自動排序、不重複
guests.add("Liam"); guests.add("Thomas"); guests.add("Alice"); guests.add("Liam");
System.out.println(guests); // [Alice, Liam, Thomas]
Map<String, Integer> ages = new HashMap<>();
ages.put("Liam", 30);
ages.put("Liam", 31); // 同一個鍵:覆蓋
ages.get("Nobody"); // null(不存在的鍵)
ages.getOrDefault("Nobody", -1); // -1
Map<Character, Integer> freq = new TreeMap<>(); // 經典應用:計數
for (char c : "mississippi".toCharArray()) {
freq.put(c, freq.getOrDefault(c, 0) + 1);
}
System.out.println(freq); // {i=4, m=1, p=2, s=4}
List<Integer> nums = List.of(3, 1, 4, 1, 5); // of():不可變的集合
nums.add(9); // UnsupportedOperationException!
List<Integer> mutable = new ArrayList<>(nums); // 要可變就複製一份
變數宣告用介面, 用實作:。 日後改成 只改一個字;方法參數也宣告成 /,接受度最大。
不確定就用 //:它們是各類別的預設選擇; 需要排序走訪才換 ,需要頻繁在前端插刪才換 。
// 建立的集合不可變:適合常數表;要改就 複製。
、、 都是介面裡帶有實作的方法( 關鍵字), Java 8 引入它是為了在不弄壞既有實作的前提下擴充介面。它的意義是:實作類別可以覆寫它,但不覆寫也能用。 介面仍然不能有實例欄位,所以 default 方法只能透過其他抽象方法完成工作。
練習
把第 [sec:adt] 節的 與 改成 /。 內部類別 的 欄位型別要改成什麼? 的回傳型別呢? 改完後 裡的 可以改寫成什麼更簡潔的形式?
是內部(非 static)類別,可以直接使用外層的 :;。 因為 回傳 ,可以寫 不變, 但若想用 專屬方法(如 )就不再需要轉型。 若你把 改成 ,它就看不到外層的 ,必須自己宣告 。
對每個需求選擇最合適的介面與實作,並說明理由: (a) 記錄一篇文章中每個單字出現的次數,最後依字母順序印出; (b) 判斷使用者輸入的 ID 是否曾出現過(百萬筆); (c) 依時間順序保存聊天訊息並能隨機讀取第 \(i\) 則; (d) 待處理的工作佇列:先進先出。
(a) ,用 (需要依鍵排序);或先用 計數再排序一次。 (b) ,用 (只要 contains,不需要順序,平均 \(\bigO{1}\))。 (c) ,用 (尾端 add 攤銷 \(\bigO{1}\),隨機 get \(\bigO{1}\))。 (d) ,用 (兩端 \(\bigO{1}\))。