CodeGym /課程 /JAVA 25 SELF /不可變集合:Collections.unmodifiable

不可變集合:Collections.unmodifiable

JAVA 25 SELF
等級 33 , 課堂 2
開放

1. 集合可變性的問題

在 Java 中,集合就像一個貨倉:任何人都可以來加點東西、拿走些東西、或改來改去。有時這很方便,但在大型程式中會變成頭痛的來源。想像一下,你把自己類別中的商品清單傳到外部,結果有人把一半項目刪了。或者——更刺激的是——在多執行緒程式中,一個執行緒新增元素,另一個執行緒在讀取:結果可能出乎意料,錯誤也很難捉(例如 ConcurrentModificationException)。

以下示例說明為什麼集合的可變性是 bug 的來源:

import java.util.*;

public class Inventory {
    private List<String> products = new ArrayList<>();

    public Inventory() {
        products.add("茶");
        products.add("咖啡");
    }

    public List<String> getProducts() {
        // 危險!返回了內部清單的參照
        return products;
    }
}
public class Main {
    public static void main(String[] args) {
        Inventory inv = new Inventory();
        List<String> external = inv.getProducts();
        external.remove("茶"); // 糟了!現在庫存裡沒有茶了
        System.out.println(inv.getProducts()); // [咖啡]
    }
}

看出玄機了嗎?一個方法返回內部集合,另一段程式碼就把它改了。這樣很容易不小心破壞本該被保護的資料。

2. 建立不可變集合:Collections.unmodifiable*

為了避免這類狀況,Java 提供了有效的保護:可以用 Collections 類別中的特殊包裝將集合變成「不可變」:

  • Collections.unmodifiableList(list)
  • Collections.unmodifiableSet(set)
  • Collections.unmodifiableMap(map)

這是怎麼運作的?先建立一個普通集合,然後用「不可變」外殼把它包起來:

import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> drinks = new ArrayList<>();
        drinks.add("茶");
        drinks.add("咖啡");

        List<String> immutableDrinks = Collections.unmodifiableList(drinks);

        System.out.println(immutableDrinks); // [茶, 咖啡]

        // 試著新增元素
        immutableDrinks.add("可可"); // 砰!UnsupportedOperationException
    }
}

嘗試修改這樣的集合會拋出 UnsupportedOperationException。這就像在盒子上貼了一個巨大的標籤 「請勿觸碰!」——任何想新增或刪除的人都會被阻止(或在呼叫堆疊上被教訓)。

範例:保護內部狀態

讓我們修正前面範例中的 Inventory 類別:

import java.util.*;

public class Inventory {
    private List<String> products = new ArrayList<>();

    public Inventory() {
        products.add("茶");
        products.add("咖啡");
    }

    public List<String> getProducts() {
        // 現在返回包裝
        return Collections.unmodifiableList(products);
    }
}

現在,如果有人嘗試修改取得的清單,就會得到例外。

3. 不可變集合的行為:淺層保護

重要的是要理解:unmodifiableList 與它的「兄弟們」只是把原始集合包起來。它們不會建立副本——對原始集合(包在「裡面」的那個)的任何修改,都會在包裝中看得到!

示範

import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> drinks = new ArrayList<>();
        drinks.add("茶");
        List<String> immutableDrinks = Collections.unmodifiableList(drinks);

        drinks.add("咖啡"); // 修改原始集合
        System.out.println(immutableDrinks); // [茶, 咖啡] — 元素出現了!
    }
}

結論:包裝只會防止透過包裝本身進行修改。如果有人握有原始集合的參照,他仍然可以修改它。

4. 深層不可變:迷思與現實

unmodifiable* 包裝只讓集合在外觀上不可變。但如果集合中包含可變的物件,這些物件仍然可以被修改!

範例

import java.util.*;

class Product {
    String name;
    Product(String name) {
        this.name = name;
    }
    public String toString() {
        return name;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("茶"));
        List<Product> immutableProducts = Collections.unmodifiableList(products);

        // 修改集合中的物件
        immutableProducts.get(0).name = "咖啡";
        System.out.println(immutableProducts); // [咖啡]
    }
}

結論:

  • 集合是「不可變」的,但其中的物件不是。
  • 若要達到完全(深層)的不可變,請使用不可變物件(例如 StringIntegerrecord 類別,或讓你自己的類別成為 immutable)。

5. 何時使用不可變集合

用於保護內部狀態

如果你的類別儲存了一個集合,並且要對外提供它,一定要返回包裝,避免任何人不小心(或故意)修改你的資料:

public List<String> getProducts() {
    return Collections.unmodifiableList(products);
}

在多執行緒程式中

在多執行緒應用中,可變集合是問題的來源(race condition、ConcurrentModificationException 等各種「人生樂事」)。如果集合在建立後不需要再修改——就把它做成不可變的。

在層與層之間傳遞資料

如果你要把集合從程式的一個層級傳遞到另一個層級(例如從 DAO 到 service),請傳遞不可變的副本或包裝——這能避免意外修改。

6. 實用範例

範例 1:保護學生清單

import java.util.*;

public class Group {
    private final List<String> students = new ArrayList<>();

    public void addStudent(String name) {
        students.add(name);
    }

    public List<String> getStudents() {
        return Collections.unmodifiableList(students);
    }
}

現在,沒有人能透過 getStudents() 直接新增或刪除學生。

範例 2:不可變的 Map

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> grades = new HashMap<>();
        grades.put("瓦夏", 5);
        grades.put("瑪莎", 4);

        Map<String, Integer> immutableGrades = Collections.unmodifiableMap(grades);

        // immutableGrades.put("彼佳", 3); // UnsupportedOperationException
    }
}

7. 有用的細節

現代的替代方案:List.of, Set.of, Map.of

自 Java 9 起,出現了更方便的不可變集合建立方式:

List<String> drinks = List.of("茶", "咖啡");
Set<String> fruits = Set.of("蘋果", "香蕉");
Map<String, Integer> ages = Map.of("瓦夏", 20, "瑪莎", 21);
  • 這些集合是不可變的(任何修改嘗試都會丟出例外)。
  • 它們沒有「原始」的可變集合(不同於 Collections.unmodifiable*)。
  • 不允許 null 值。

另外自 Java 10 起有複製方法:List.copyOfSet.copyOfMap.copyOf——會建立傳入集合的不可變副本。

建立不可變集合方式的比較

方式 深層不可變性 能否修改原始集合? 允許 null? Java 版本
Collections.unmodifiableList(list)
1.2
List.of(...), Set.of(...), Map.of(...)
否(沒有原始集合) 9+

8. 使用不可變集合時的常見錯誤

錯誤 #1:在建立包裝後仍修改原始集合。你建立了 unmodifiableList,但之後有人修改了原始清單。包裝無法防止這種情況——變更會在所有使用該包裝的地方顯示出來。

錯誤 #2:期待深層不可變。許多人以為既然集合不可變,那集合內的物件也不能改。實際上保護的是結構(透過集合的新增/刪除/修改),而不是物件內容。

錯誤 #3:在現代工廠方法中使用含 null 的不可變集合。透過 List.ofSet.ofMap.of 建立的集合不允許 null。嘗試新增或取得 null 會導致拋出例外。

錯誤 #4:將可變集合的參照暴露到外部。如果你把內部集合的參照(未經包裝)返回到外部,你就失去了對資料的控制——這是通往 bug 與不變式「洩漏」的捷徑。

錯誤 #5:在期望可變性的程式碼中使用不可變集合。如果外部程式碼嘗試修改集合(例如新增元素),就會得到 UnsupportedOperationException。請確保消費端知道資料是不可變的。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION