CodeGym /コース /JAVA 25 SELF /Generics コレクションのシリアライズ: 特徴

Generics コレクションのシリアライズ: 特徴

JAVA 25 SELF
レベル 45 , レッスン 1
使用可能

1. Generics コレクションのシリアライズとデシリアライズ

Java のジェネリクス(総称型)は魔法ではなく、コンパイラが支える一種の「仕組み」です。コンパイル段階で Generics の型パラメータ情報は消去されます(これは type erasure、すなわち「型消去」と呼ばれます)。つまり実行時(runtime)には、List<String>List<Object>List<Integer> と区別されません。どれも単なる List であり、JVM はそこにどんな型の要素が入っているかを知りません。

例を見てみましょう:

List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

System.out.println(stringList.getClass() == intList.getClass()); // true!

ここが少しトリッキーなところです。文字列用のリストと数値用のリストを作っています。コンパイル時には、Java は List<String> に文字列以外を入れたり、List<Integer> に数値以外を入れたりしないよう厳密にチェックします。しかしプログラムを実行した瞬間、その違いは消えます。JVM にとってはどちらも単なる ArrayList で、どんな要素型であるべきかは検査できません。だから、2 つのリストのクラスを比較すると(stringList.getClass() == intList.getClass())、結果は true になります。

ここから大事な結論が得られます。Java の Generics は、主にコンパイル時の利便性と安全性のために存在します。しかしランタイムではこれらの「ラベル」は失われます。したがってコレクションをシリアライズすると、ファイルに書き込まれるのはデータそのものであり、Generics の型情報ではありません。つまり値のリストは保存されますが、そのファイルからそれが List<String> だったのか、List<Object>List<Integer> だったのかは分かりません。

もう一つの例: List<String> のシリアライズとデシリアライズ

import java.io.*;
import java.util.*;

public class GenericSerializationDemo {
    public static void main(String[] args) throws Exception {
        List<String> fruits = new ArrayList<>();
        fruits.add("リンゴ");
        fruits.add("バナナ");
        fruits.add("オレンジ");

        // シリアライズ
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("fruits.ser"))) {
            oos.writeObject(fruits);
        }

        // デシリアライズ
        List<String> loadedFruits;
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("fruits.ser"))) {
            loadedFruits = (List<String>) ois.readObject();
        }

        System.out.println(loadedFruits); // [リンゴ, バナナ, オレンジ]
    }
}

次の行に注目してください:

loadedFruits = (List<String>) ois.readObject();

ここでは結果を明示的に List<String> にキャストしていますが、実行時の実体は単なる ArrayList です。コンパイラは、それが本当に文字列のリストであるかを検証できません。もし万一文字列以外が混ざっていたら、プログラム実行中に ClassCastException を受け取ることになります。

2. Generics コレクションのデシリアライズ時の問題

要素型情報の喪失

Generics の型パラメータ情報は消去されるため、デシリアライズ後にコレクションの中身が期待する型であることを Java は保証できません。手に入るのは「raw 型」のコレクション(raw type)であり、コンパイラは文句を言いませんが、問題はランタイムで表面化する可能性があります。

問題のデモ

List rawList = new ArrayList();
rawList.add("ネコ");
rawList.add(42); // Integer!
// デシリアライズ
List<String> loadedCats = (List<String>) ois.readObject();
String cat = loadedCats.get(1); // ClassCastException!

Unchecked cast warning

コンパイラは潜在的な問題を正直に警告します:

Note: GenericSerializationDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

この警告は、検査なしにキャストしており、コレクションに予期しない型のオブジェクトが含まれている可能性があることを意味します。

3. Generics コレクションのシリアライズの特徴

ファイルに generic パラメータ情報は含まれない

List<String>List<Integer> をシリアライズしても、それが文字列や数値だったといった情報はファイルには含まれません。コレクションの中身は「そのまま」順番にオブジェクトとしてシリアライズされます。

シリアライズされたファイルをテキストエディタで開いても、<String><Integer> といった記述は見当たりません。これらはソースコードとコンパイラのレベルにだけ存在する情報です。

例: 異なるコレクションのシリアライズ

List<Integer> numbers = Arrays.asList(1, 2, 3);
List<String> words = Arrays.asList("一", "二", "三");

// シリアライズ
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.ser"))) {
    oos.writeObject(numbers);
    oos.writeObject(words);
}

test.ser の中身は、単に 2 つの ArrayList オブジェクトであり、generic パラメータに関する情報はありません。

raw 型コレクションのデシリアライズの問題

generic パラメータなし(raw 型)のリストをシリアライズしておきながら、List<String> としてデシリアライズすると、コンパイラは型の正しさを検証できず、ランタイムエラーの原因になります。

4. Generics コレクションをシリアライズする際のベストプラクティス

要素の期待型を文書化する。
API がコレクションをシリアライズして返す場合、要素型を必ず明記しましょう。例: 「このメソッドはシリアライズされた List<User> を返します」。

デシリアライズ後に要素型を検査する。
コレクションをデシリアライズしたら、すべての要素が期待する型であることを確認するのが有用です(特にデータソースを管理できない場合)。

for (Object obj : loadedList) {
    if (!(obj instanceof String)) {
        throw new IllegalStateException("文字列を期待しましたが、見つかったのは: " + obj.getClass());
    }
}

不変コレクションを使う。
読み取り専用でシリアライズするなら、不変コレクション(List.copyOfCollections.unmodifiableList)を使いましょう。デシリアライズ後の偶発的な変更を防げます。

1 つのコレクションに型を混在させない。
要素の型が混在するコレクション(たとえば、さまざまなクラスが入った List<Object>)はシリアライズしないようにしましょう。デシリアライズが複雑になり、エラーの原因になります。

警告の抑制は慎重に。
要素型が正しいと確信できる場合に限り、アノテーション @SuppressWarnings("unchecked") でコンパイラ警告を抑制できます:

@SuppressWarnings("unchecked")
List<String> loaded = (List<String>) ois.readObject();

ただし乱用は禁物です。問題を本番まで隠してしまいがちです。

5. 例: 独自クラスを含むコレクションのシリアライズとデシリアライズ

次のような User クラスがあるとします:

import java.io.Serializable;

public class User implements Serializable {
    private String name;
    private int age;
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String toString() {
        return name + " (" + age + ")";
    }
}

ユーザー一覧をシリアライズします:

List<User> users = Arrays.asList(
    new User("アリス", 30),
    new User("ボブ", 25)
);

// シリアライズ
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
    oos.writeObject(users);
}

// デシリアライズ
List<User> loadedUsers;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("users.ser"))) {
    loadedUsers = (List<User>) ois.readObject();
}

System.out.println(loadedUsers); // [アリス (30), ボブ (25)]

うまく動作します。 ただし、誰かがシリアライズ済みファイルに別のクラスのオブジェクトを紛れ込ませると、要素を User として読み取ろうとした際に ClassCastException を受け取る可能性があります。

6. 入れ子の Generics コレクションのシリアライズ

コレクションは入れ子になり得ます(例: List<List<String>>Map<String, List<User>> など)。Java はこれらの構造を再帰的にシリアライズしますが、ルールは同じです。

  • 入れ子になったすべてのコレクションと要素はシリアライズ可能である必要があります。
  • generic パラメータの情報はやはり消去されます。

例: リストのリストのシリアライズ

List<List<String>> matrix = new ArrayList<>();
matrix.add(Arrays.asList("a", "b"));
matrix.add(Arrays.asList("c", "d"));

// シリアライズ
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("matrix.ser"))) {
    oos.writeObject(matrix);
}

// デシリアライズ
List<List<String>> loadedMatrix;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("matrix.ser"))) {
    loadedMatrix = (List<List<String>>) ois.readObject();
}

System.out.println(loadedMatrix); // [[a, b], [c, d]]

7. 役立つ細かなポイント

Generics コレクションのシリアライズと実装の違い

ある実装でシリアライズし、別の実装としてデシリアライズしてしまうケースがあります。たとえば ArrayList をシリアライズしたのに、LinkedList としてデシリアライズすると、型変換エラーになります。

List<String> list = new ArrayList<>();
// ...
List<String> loaded = (LinkedList<String>) ois.readObject(); // ClassCastException!

ヒント: シリアライズしたのと同じ型でデシリアライズするか、具体的な実装が重要でないならインターフェース(List)で扱いましょう。

ライブラリの利用(例: Gson, Jackson)

JSON 用ライブラリ(GsonJackson など)は Generics を伴うコレクションのシリアライズ/デシリアライズをサポートしますが、型消去のため、デシリアライズ時には型を明示する必要があります。Gson の例:

Type type = new com.google.gson.reflect.TypeToken<List<User>>(){}.getType();
List<User> users = gson.fromJson(json, type);

8. Map と Set における Generics とシリアライズ

上記ルールは、他の Generics コレクションにも同様に当てはまります。

  • Map<String, Integer> をシリアライズしても、キーや値の型情報は保存されません。
  • デシリアライズ時には必要な型にキャストし、中身に注意を払う必要があります。

例: Map のシリアライズ

Map<String, Integer> scores = new HashMap<>();
scores.put("ヴァーシャ", 90);
scores.put("ペーチャ", 85);

// シリアライズ
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("scores.ser"))) {
    oos.writeObject(scores);
}

// デシリアライズ
Map<String, Integer> loadedScores;
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("scores.ser"))) {
    loadedScores = (Map<String, Integer>) ois.readObject();
}

System.out.println(loadedScores); // {ヴァーシャ=90, ペーチャ=85}

9. Generics コレクションのシリアライズでよくある間違い

エラー 1: デシリアライズ時の ClassCastException。 List<String> としてデシリアライズしたのに、別の型のオブジェクトが混ざっていると、ランタイムで ClassCastException が発生します。常にコレクションの中身を確認しましょう。

エラー 2: シリアライズ不可の要素による NotSerializableException。 コレクションの要素のうち 1 つでも Serializable を実装していないと、NotSerializableException で失敗します。コレクションに含まれ得るすべてのクラスがシリアライズ可能か確認しましょう。

エラー 3: generic パラメータ情報の喪失。 デシリアライズ後に generic パラメータに頼らないでください——ランタイムには存在しません。データの正当性に不安がある場合は、明示的な型チェックを行いましょう。

エラー 4: コレクション実装の不一致。 ArrayList をシリアライズしておきながら、LinkedList としてデシリアライズすると、キャストエラーになります。基本的には同じ型でデシリアライズするようにしましょう。

エラー 5: クラスのバージョン不整合。 コレクション要素のクラス構造がシリアライズ後に変更された(例: フィールドを追加した)場合、デシリアライズ時にエラーが起きることがあります。バージョン管理には serialVersionUID を使用しましょう。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION