JavaのImmutable Collections: List.of、copyOf、unmodifiable

1. 概要

Javaには「変更不可能な」collectionを作る方法がたくさんあります:List.of、List.copyOf、Collections.unmodifiableList、Stream.toList()… 一見すると同じように見えますが、3つの点で異なります:データをコピーするかどうか、nullを受け入れるかどうか、そして元のcollectionの変更を反映するかどうかです。

この記事では、具体的な例を示しながら、各方法を説明していきます。読み終わったら、正しいAPIを選択し、予期しないNullPointerExceptionを避け、正しいdefensive copyを書くことができるようになります。

2. なぜimmutable collectionが必要なのか

次のOrderクラスを見てください:

import java.util.List;

public class Order {
    private final List<String> items;

    public Order(List<String> items) {
        this.items = items;
    }

    public List<String> getItems() {
        return items;
    }
}

キーワードfinalは参照をロックするだけで、listの内容はロックしません。外部からでも編集できます:

List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);

items.add("Mouse");              // 元のリストを編集
order.getItems().add("Keyboard"); // getterを通じて編集

System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]

Orderはもはや自分の状態を制御できません。オブジェクトが複数のスレッド間で共有されるとき、このタイプのバグはさらに見つけにくくなります。Immutable collectionはこの問題を解決します:誰も編集できないため、同期化なしで自由に共有できます。

3. List.of、Set.of、Map.of

Java 9以降、factory methodofは1行でimmutable collectionを作成できます:

List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);

statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException

add、remove、set、clear、sort、putなど、データを変更するすべてのメソッドはUnsupportedOperationExceptionをスローします。例外は常にスローされます。実は変更しない操作(例えば、存在しない要素をremoveする)でもそうです。

Map.ofは最大10個のキー-バリューペアのオーバーロードしかありません。それ以上必要な場合は、Map.ofEntriesを使用します:

Map<String, Integer> prices = Map.ofEntries(
        Map.entry("Laptop", 25_000_000),
        Map.entry("Mouse", 300_000)
);

3.1. nullを受け入れない

ofで作成されたcollectionはinitializationの直後にnullを拒否します:

List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException

さらに注意が必要な点があります:nullをクエリすることでもエラーがスローされます。

List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException

ArrayListの場合、上記の呼び出しはfalseを返します。古いコードにlist.contains(input)があり、inputがnullの可能性がある場合、List.ofに変更するとこのコードが破綻します。

3.2. SetとMap:重複と順序

Set.ofとMap.ofは重複する要素を黙って無視せず、エラーをスローします:

Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java

Set.ofとMap.ofの走査順序は保証されません。OpenJDKでは、この順序はJVM実行ごとに変わります。したがって、Set.ofのtoString()文字列を比較するテストは、時々パスし、時々失敗する可能性があります。順序を保つ必要がある場合は、LinkedHashSetまたはLinkedHashMapを使用してからラップします(セクション5を参照)。

4. List.copyOf:編集不可能なコピー

List.ofは要素があらかじめわかっている場合に適しています。既にcollectionがある場合(例えば、constructorに渡されたパラメータ)は、List.copyOfを使用します(Java 10以降):

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);

source.add("Keyboard");

System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]

copyは独立したコピーです。sourceへの変更はそれに影響しません。List.ofと同様に、copyOfはソースcollectionがnullを含む場合、NullPointerExceptionをスローします。

興味深い詳細:入力がList.ofまたはList.copyOfで作成されたimmutable listすでにある場合、OpenJDKは再度コピーせずそのインスタンスを返します:

List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true

そのため、「確認のため」copyOfを呼び出しても、ほぼコストがかかりません。ただし、これは実装の詳細であるため、==比較に基づくロジックを書かないでください。

同様にSet.copyOfとMap.copyOfもあります。特にSet.copyOfは、ソースが重複する要素を持つ場合、エラーをスローせず、1つだけ保持します(ソースがListの可能性があるため)。

5. Collections.unmodifiableList:単なるビュー

Collections.unmodifiableListはJava 1.2以降にあり、まったく異なる動作をします。それはコピーしません。元のcollectionを書き込み防止クラスでラップするだけです:

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);

source.add("Keyboard");

System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException

viewを通じて編集することはできませんが、sourceへの参照を保持している人は編集でき、その変更がviewにすぐに反映されます。したがって、unmodifiableは「この参照を通じて編集できない」という意味であり、「不変」という意味ではありません。

このビューの動作は、呼び出し元に最新のデータを見させ、編集させたくない場合に便利です:

public class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    public void addItem(String item) {
        items.add(item);
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);
    }
}

Getterはコピーなしでビューを返すため、コストはO(1)です。呼び出し元は読み取り専用ですが、ShoppingCartは内部で完全に編集できます。

最後の違い:このビューはnullを受け入れます。contains(null)の呼び出しはエラーをスローする代わりにfalseを返します。

6. その他のオプションと比較表

よく混同されるAPIがいくつかあります:

// Java 16+:結果は編集不可能、nullを受け入れる
List<String> a = Stream.of("A", null).toList();

// Java 10+:結果は編集不可能、nullを受け入れない
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// サイズは固定ですが、set()でき、元の配列に直接書き込みます
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

比較表:

API コピー? null ソースの変更が見える? 編集可能?
List.of(...) ソースがない NPEをスロー 該当なし いいえ
List.copyOf(c) はい NPEをスロー いいえ いいえ
Collections.unmodifiableList(l) いいえ(ビュー) 許可 はい いいえ(ビュー経由)
stream.toList() はい 許可 いいえ いいえ
Collectors.toUnmodifiableList() はい NPEをスロー いいえ いいえ
Arrays.asList(arr) いいえ(配列ビュー) 許可 はい setのみ

7. 浅いイミュータビリティ(shallow immutability)

上記のすべてのAPIは、collectionの構造をロックするだけで、内部の要素をロックしません:

public class OrderLine {
    private int quantity;

    public OrderLine(int quantity) { this.quantity = quantity; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99

要素を追加または削除することはできませんが、各要素の内容は編集できます。真の不変性を望む場合、要素自体も不変である必要があります。最も簡潔な方法はrecordを使用することです:

public record OrderLine(String product, int quantity) {}

まとめると、これは不変aggregateのための標準的なdefensive copyパターンです:

import java.util.List;

public record Order(String customer, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);
    }
}

Compact constructorは、fieldに割り当てる前に、パラメータlinesを編集不可能なコピーに置き換えます。Accessorlines()がそのコピーを返すため、getterで再度コピーする必要はありません。linesがnullであるかnullを含む場合、オブジェクトはcreation時に失敗し、より遠い場所でエラーが発生するのではなく。

8. 動作のテスト

pom.xmlにJUnit 5とAssertJを追加します。Spring Boot 3.xを使用している場合、spring-boot-starter-testには両方がすでに含まれています:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

以下のテストは、Orderとviewの3つの主要な動作を検証します:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderTest {

    @Test
    void shouldNotReflectChangesToSourceList() {
        List<OrderLine> source = new ArrayList<>();
        source.add(new OrderLine("Laptop", 1));

        Order order = new Order("An", source);
        source.add(new OrderLine("Mouse", 2));

        assertThat(order.lines()).hasSize(1);
    }

    @Test
    void shouldRejectModificationThroughAccessor() {
        Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));

        assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void unmodifiableViewShouldReflectSourceChanges() {
        List<String> source = new ArrayList<>(List.of("A"));
        List<String> view = Collections.unmodifiableList(source);

        source.add("B");

        assertThat(view).containsExactly("A", "B");
    }
}

最初のテストは、compact constructorのList.copyOfがOrderをソースlistから分離していることを確認します。2番目のテストは、accessorが編集データへのパスを開かないことを保証します。最後のテストは、unmodifiableListがビューであることを記録します。これにより、誰もunmodifiableListが独立したコピーであると勘違いすることはありません。

9. 一般的なエラーと使うべきでない場合

注意: List.of(...).contains(null)とindexOf(null)はNullPointerExceptionをスローします。事前にnullをチェックするか、リストを走査するときにObjects.equalsを使用してください。

その他の一般的なエラー:

  • unmodifiableListが不変だと思うこと。 それは単なるビューです。ソースがまだどこかに保持されている場合、データはまだ変更できます。スナップショットが必要な場合は、List.copyOfを使用します。
  • collect(Collectors.toList())を機械的にtoList()に変更する。 Collectors.toList()は編集可能なArrayListを返します。結果の上でaddまたはsortを呼び出すコードがある場合、そのコードは実行時にUnsupportedOperationExceptionをスローします。
  • その場でsortを呼び出すこと。 List.of(...).sort(...)は常にエラーをスローします。先にArrayListにコピーするか、stream().sorted().toList()を使用する必要があります。
  • ログ、JSON、またはテストでSet.ofまたはMap.ofの順序に依存すること。
  • nullを含む可能性のある外部ソースからのデータ(JSON、データベース結果)を直接List.copyOfに渡すこと。 事前にフィルタリングするか、nullが有効な値の場合はstream().toList()を使用します。

immutable collectionを使うべきでないケースがいくつかあります:

  • Short-lived、ローカルなスコープで絶えず変更する必要があるcollection(例えば、method内でresultをバッファリングする場合)。ArrayListはシンプルで速いです。
  • 一部のライブラリは編集可能なcollectionが必要です。例えば、Hibernateを使用したJPA entityでは、@OneToManyフィールドがHibernateが管理するために編集可能なcollectionである必要があります。この場合、内部でArrayListを保持し、getterからはビューを返します。
  • 頻繁に更新される非常に大きなcollection。毎回「編集」のたびに全体をコピーすると、O(n)のコストがかかります。

10. 結論

3つの主要なAPIは3つの異なる目的を果たします:

  • List.ofは、固定リストを素早く作成し、nullを含みません。
  • List.copyOfは独立したスナップショットを作成します。これはconstructorまたはrecordでのdefensive copyのデフォルト選択肢です。
  • Collections.unmodifiableListは、変化し続けるデータの読み取り専用ビューです。それは安いですが、不変ではありません。

JDK内のすべてのimmutable collectionは浅い不変性しかないことも覚えておく必要があります。それらをrecordのような不変要素と組み合わせて、スレッド間で共有する場合に実際に安全なオブジェクトを作成します。

javacollectionsimmutablejava-21clean-code