Java | Java 詳細・モダン文法:ジェネリクス – 型消去の概念

Java Java
スポンサーリンク

型消去を一言でいうと

型消去(type erasure)は、

「ジェネリクスの <T> みたいな型情報は“コンパイル時だけ”に使われて、コンパイル後のクラスファイル(実行時)からは消えてしまう」

という仕組みのことです。

つまり Java は、

コンパイル時
List<String>List<Integer> をきっちり区別して型チェックする

実行時
→ どっちもただの List(生の型)として動いている

という世界観で動いています。

この「コンパイル時は厳密・実行時は消える」というギャップが、
ジェネリクス特有の「できないこと」「書き方の制限」の正体です。


まずは感覚をつかむ:コンパイル時と実行時の違い

コンパイル時はちゃんと区別されている

例えば、次のコードを見てください。

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

public class CompileTimeCheck {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        strings.add("hello");
        // strings.add(123);   // コンパイルエラー

        String s = strings.get(0); // キャスト不要
        System.out.println(s.toUpperCase());
    }
}
Java

ここではコンパイラが、

List<String> だから、add できるのは String だけ
get() の戻り値は必ず String になる

と理解してくれています。

これが「コンパイル時に型パラメータが効いている」状態です。

実行時は「型パラメータはないもの」として動いている

一方で、実行時にはどう見えるか。

実際に JVM の中で動くクラスファイルには、
List<String>List<Integer> を別々に扱うような専用の仕組み」はありません。

どちらも「List というクラス(実際には java.util.ArrayList など)」として動いていて、
型パラメータ情報は「実行時の型チェック」には使われません。

ここがポイントです。

「ジェネリクスの型情報は、コンパイルで安全性を高めるために使われるけど、
バイトコード(クラスファイル)のレベルでは、基本的に消されている」

この「消す(erasure)」という処理が、型消去です。


実際に何が起きているのかをイメージする

コンパイル前のソースコード

例えば、こんな簡単なクラスを考えます。

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

public class Sample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("hello");
        String s = list.get(0);
        System.out.println(s);
    }
}
Java

ソースコードレベルでは List<String> です。

コンパイル後のイメージ(かなりざっくり)

実際のコンパイラがどう変換しているかを“イメージ”として書くと、
だいたいこんな変化が起きています。

List<String> → 実際には「生の List として扱うコード」になる
list.get(0) → 戻り値は Object になるので、内部的に (String) のキャストが挿入される

つまり、コンパイル後のイメージはこんな感じです。

List list = new ArrayList();
list.add("hello");        // 内部的には Object にアップキャスト
String s = (String) list.get(0);  // コンパイラが勝手にキャストを挿入
Java

※これはあくまでイメージです。実際にコンパイラがどうバイトコードを生成しているかはもっと低レベルですが、考え方としてはほぼこうです。

大事なのは、

あなたがソースコードに書く (String) のキャストを、コンパイラが「代わりに自動で挿入している」
ジェネリクスのおかげで、「そのキャストが正しいかどうか」をコンパイル時にチェックしてくれている

という構造です。


型消去だからこそ「できないこと」

1. instanceof List<String> のようなことは書けない

よくある「え、なんでダメなの?」がこれです。

List<String> list = new ArrayList<>();

if (list instanceof List<String>) {   // コンパイルエラー
    ...
}
Java

なぜダメかというと、実行時には List<String> なのか List<Integer> なのか分からないからです。
型消去によって、「実行時にはただの List」としてしか存在しないので、

instanceof List → OK
instanceof List<String> → NG

という制限がかかります。

instanceof は「実行時の型情報」に基づいて判定する構文なので、
実行時に残っていない情報(型パラメータ)を条件にすることはできません。

2. new T[] のような配列は作れない

ジェネリッククラスの中で、こんなコードを書こうとするとコンパイルエラーになります。

public class Box<T> {
    private T[] array;

    public Box(int size) {
        // array = new T[size];   // コンパイルエラー
    }
}
Java

「実行時に T が何か分からない」ので、配列を安全に作れない、という制約です。

配列は実行時にも「要素型」をチェックします。

String[]Integer を代入すると、実行時に ArrayStoreException が飛ぶはずです。
これは「配列は実行時も要素の型を覚えている」からできるチェックです。

一方、ジェネリクスの型パラメータは実行時には消えているので、
new T[size] のように「要素の型が T の配列」を作ることができません。

このケースでは、次のように「Object 配列を作ってからキャストして使う」パターンがよく使われます。

@SuppressWarnings("unchecked")
public Box(int size) {
    this.array = (T[]) new Object[size];
}
Java

ここで @SuppressWarnings("unchecked") をつけているのは、
コンパイラに「ここは意図的に危ないことをやっているから、警告は黙ってて」と伝えるためです。

初心者のうちは、「配列とジェネリクスは相性が悪い。基本は List を使う」と覚えておいても構いません。

3. static な文脈で型パラメータは使えない

ジェネリッククラスの型パラメータは、そのクラスのインスタンスに紐づく情報です。
しかし型消去により、実行時には T の具体的な型は分かりません。

そのため、static フィールドや static メソッドの型パラメータとして、クラスの T を直接使うことはできません。

public class Box<T> {
    // private static T value;  // コンパイルエラー
}
Java

static なものは「クラス全体で共有されるもの」なので、
インスタンスごとの型 T と結びつけることはできない、というルールです。


型消去が「後方互換性」のために選ばれたこと

古いコードと混ぜて動かすための設計

Java にジェネリクスが入ったのは Java 5 からですが、
それ以前にも ListMap はすでに大量のプロジェクトで使われていました。

そのとき Java 設計者が重要視したのが、

「古いコード(ジェネリクスなし)と、新しいコード(ジェネリクスあり)を
同じ JVM 上で安全に、かつ修正なしで共存できるようにする」

ということです。

型消去方式を採用すると、

古いクラスファイル(ジェネリクスを知らない)も、
新しいクラスファイル(ジェネリクスを使っている)も、

「実行時には同じ List として扱える」ようになります。

これによって、

既存ライブラリを作り直さなくても、ジェネリクスを後から言語に追加できた
古いバイナリが、新しい JVM でもそのまま動く

という大きなメリットがありました。

代わりに、

型パラメータは実行時には消えてしまう
instanceof List<String> が書けない
new T[] ができない

といった制限を受け入れることになった、という歴史的背景があります。


「型消去を意識すると理解しやすくなる」ポイント

「コンパイル時の世界」と「実行時の世界」を頭の中で分ける

ジェネリクスに絡む「?」「え、なんでダメ?」の多くは、
「コンパイル時の話」と「実行時の話」がごちゃっとしているときに起きます。

例えば、

List<String>Integer を入れようとするとコンパイルエラー
→ これは「コンパイル時の型検査」の話
実行時には List としてしか存在しない
→ だから instanceof List<String> はできない

というように、「どっちの世界のルールか」を分けて考える癖を付けると、
ジェネリクスの挙動が一気に整理されます。

ジェネリクスは「安全なキャストとチェックをコンパイラに肩代わりさせる仕組み」

型消去のイメージを、もっと直感的に言い換えると、

「本来なら自分で書かなきゃいけない (String) のキャストを、
コンパイラが『安全なところだけ』自動で挿入してくれている」

と考えることもできます。

List<String>
→ 「この List には String しか入れないし、String として取り出したいです」

という宣言をすると、

コンパイラは内部で 「List にアップキャストして格納 → 取り出すときに (String)
というコードを生成しつつ、それが破綻しないかをコンパイル時に厳しくチェックしてくれます。

実行時にはただの List(String) のキャストなので、
古い JVM や古いライブラリとも共存できるわけです。


まとめ:型消去を自分の中でこう位置づける

型消去(type erasure)を、初心者向けに一文でまとめると、

「ジェネリクスの <T> たちが、コンパイル時の型チェックにだけ使われて、実行時には消えてしまう仕組み」

です。

そこから自然に導かれる重要ポイントは、

コンパイル時には List<String>List<Integer> をきっちり区別してくれる
実行時にはどちらも「生の List」として扱われる
だから instanceof List<String>new T[] のようなことはできない
ジェネリクスは「安全なキャスト+コンパイル時チェック」を提供するためのもの

ということです。

タイトルとURLをコピーしました