Java | Java 詳細・モダン文法:ジェネリクス – ジェネリクスと配列の違い

Java Java
スポンサーリンク

テーマの全体像を先にまとめる

ジェネリクス(List<String> など)と配列(String[])は、どちらも「同じ型の要素をたくさん持つ」ために使いますが、
中身のルールやチェックのタイミングがかなり違います。

ここをちゃんと理解しておかないと、

なぜ List<String> は OK なのに、List<String>[] はダメなのか
なぜ new T[] は書けないのか
なぜ配列は実行時に落ちて、ジェネリクスはコンパイル時に止めてくれるのか

みたいな「モヤっと」がずっと残り続けます。

結論から言うと、

配列は「実行時」に型を厳しくチェックする世界
ジェネリクスは「コンパイル時」に型を厳しくチェックする世界

です。

ここを軸にしながら、それぞれの違いをかみ砕いて整理していきます。


まずは性格の違いをざっくり掴む

配列は「実行時に型を覚えている」

配列は、実行時にも「自分の要素型」を覚えています。

次のコードを見てください。

Object[] array = new String[10];  // これはコンパイルOK
array[0] = "hello";               // OK
array[1] = 123;                   // コンパイルOKだが、実行時に例外
Java

最後の行は、実行すると ArrayStoreException が出ます。

new String[10] で作った配列なので、
実行時には「この配列は String[] だ」と JVM が知っています。

だから、Object[] として見えていても、
実際に Integer を入れようとした瞬間に、

「この配列は String[] だから、Integer は入れちゃダメ」と実行時に怒ってくれるわけです。

つまり配列は、

コンパイル時:そこまで厳しくない(Object[]String[] を代入できる)
実行時:要素の型を覚えていて、間違った型を入れようとすると例外

という性質です。

ジェネリクスは「実行時には中身の型を覚えていない」

一方、ジェネリクス(List<T>)は「型消去」という仕組みで動いています。

List<String>List<Integer> も、
コンパイルされた後のバイトコードレベルでは「どちらも単なる List」として扱われています。

その代わり、コンパイル時にきっちりチェックしています。

List<String> list = new ArrayList<>();
list.add("hello");     // OK
list.add(123);         // コンパイルエラー
Java

ここでは、コンパイラが「List<String> に Integer は入れちゃダメ」と止めてくれます。

しかし実行時には、

List<String> → 単なる List
List<Integer> → 単なる List

としてしか存在しません。

だからこそ、instanceof List<String> が書けなかったり、
new T[] ができなかったりするのですが、それは後で整理します。


配列は共変(covariant)、ジェネリクスは不変(invariant)

配列の共変性:サブクラス配列をスーパークラス配列に代入できる

配列には「共変性」という性質があります。
難しい言葉ですが、やっていることはシンプルで、

StringObject のサブクラスだから、
String[]Object[] のサブタイプとして扱う

というルールです。

さっきの例がまさにそれです。

String[] strings = new String[10];
Object[] objects = strings;   // OK(共変性)
Java

この代入はコンパイルも通りますし、実行時にも動きます。

ただし、実行時の型チェック(ArrayStoreException の仕組み)で
「おかしなものを詰められる」のを防いでいる、という状態です。

ジェネリクスの不変性:List<String> は List<Object> ではない

ジェネリクスは基本「不変(invariant)」です。

StringObject のサブクラスだけれど、
List<String>List<Object> のサブタイプではありません。

次のコードはコンパイルエラーになります。

List<String> strings = new ArrayList<>();
// List<Object> objects = strings;   // コンパイルエラー
Java

なぜかというと、もしこれを許してしまうと、
List<Object> として見える場所で、どんな型でも add できてしまうからです。

objects.add(123); とかが書けてしまう。

でも実体は List<String> なので、本来は String 以外入ってほしくない。
しかしジェネリクスは実行時には型情報を持っていないので、
配列と違って「実行時に防ぐ仕組み」がない。

だから、そもそも「代入自体を禁止する(コンパイルエラーにする)」という設計になっています。

つまりジェネリクスは、

コンパイル時:List<String>List<Object> は完全に別物として扱う
実行時:どちらも List だが、危険な代入をさせない設計にしている

という性質です。


ジェネリクスと配列を混ぜると何がまずいのか

List<String>[] が禁止されている理由

よく出てくる謎がこれです。

List<String>[] array;           // これはコンパイルエラー
Java

なぜ String[] はいいのに、List<String>[] はダメなのか。

型消去と共変性・不変性の組み合わせで考えると、本質が見えてきます。

もし List<String>[] が作れてしまったと仮定すると、
次のような危険なコードが書けてしまいます。

(イメージとして読んでください)

List<String>[] array = new List[String][1];  // 仮にこれがOKだとする
Object[] objArray = array;                   // 配列は共変だからOKになるはず
objArray[0] = List.of(123);                  // ここで本来ありえない状態が作れる
String s = array[0].get(0);                  // 実際には Integer が入っている…
Java

実体は「String の List の配列」なのに、
Object[] として見える場所から「Integer の List」を突っ込めてしまう、
という世界になってしまいます。

でもジェネリクスは実行時には「型消去されている」ので、
配列のように「実行時に要素型を厳格にチェックして防ぐ」ことができません。

この矛盾を解消するために、

「ジェネリック型の配列をそもそも禁止する」

という設計になっています。

だから、

String[] は OK
List<String>[] は NG

というふるまいになるわけです。

new T[] が書けないのも、同じ根っこにある

ジェネリッククラスの中で new T[size] が書けないのも、
結局は「実行時に T が何か分からないので、安全に配列を作れない」という同じ理由です。

型消去によって、T の具体的な型情報はコンパイル時にしか存在しない。
一方で配列は実行時にも要素型を管理している。

この食い合わせの悪さが、「ジェネリックスと配列は相性が悪い」と言われる理由です。


例題で違いを体感する

例1:配列はコンパイル通るのに、実行時に怒られるパターン

public class ArrayExample {
    public static void main(String[] args) {
        String[] strings = new String[2];
        Object[] objects = strings;   // 共変

        objects[0] = "hello";         // OK
        objects[1] = 123;             // コンパイルOKだが…

        System.out.println(strings[0]);
        System.out.println(strings[1]); // ここに Integer が入っていたらまずい
    }
}
Java

実際に走らせると、objects[1] = 123; の行で ArrayStoreException が投げられます。

配列は、「代入時に毎回、実行時型チェックをしている」イメージです。

例2:ジェネリクスならコンパイル時に止めてくれる

同じようなことをジェネリクスでやろうとすると、
そもそもコンパイルが通りません。

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

public class GenericsExample {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        strings.add("hello");

        // List<Object> objects = strings;   // コンパイルエラー

        // もしこれが OK だったら、次が書けてしまう:
        // objects.add(123);  // 実体は List<String> なのに Integer を入れてしまう
    }
}
Java

ここで「コンパイル時に止める」という設計にすることで、

実行時に妙な状態(List<String> の中に Integer)が紛れ込む可能性を根本から排除しています。

配列は「緩めに代入を許し、実行時にチェックで守る」
ジェネリクスは「そもそも危ない代入をさせない」

という性格の違いだと捉えてください。


どちらを使うべきかのざっくり指針

コレクション的な用途は、基本ジェネリクス(List / Map)を使う

今の Java で、「可変長で要素を増やしたい」「コレクションとして扱いたい」場合は、
ほぼすべて List<T>Map<K, V> のようなジェネリックコレクションを使います。

理由はシンプルで、

型安全性が高い(コンパイル時に守られる)
サイズの増減や便利メソッド(ソート・検索など)が揃っている
配列+ジェネリクス特有の変な罠を避けられる

からです。

配列(T[])は、

プリミティブ型(int[], double[] など)を効率よく扱いたいとき
固定サイズのデータ、低レベルな処理をするとき
既存 API が配列を返してくるとき

など、少し「低レイヤー寄り」の用途で使うことが多いです。

「ジェネリクスと配列の違い」を頭に置いておくと罠を踏みにくくなる

実務では、「配列でジェネリクスを扱おうとして変なエラーになる」「List<String>[] が書けなくてハマる」
といった罠にハマることがあります。

そういうときに、

配列は実行時に型を覚えている(共変)
ジェネリクスはコンパイル時だけ型を見て、実行時には消えている(不変)

という違いを覚えておくと、「ああ、だからこれは危険扱いされているんだな」と理解できます。


まとめ:ジェネリクスと配列の違いを一言で整理する

ジェネリクスと配列の違いを、初心者向けにぎゅっとまとめると、

配列
→ 実行時にも要素の型情報を持っていて、誤った代入は実行時に ArrayStoreException で防ぐ。
→ 共変(String[]Object[] として扱える)が、その分、実行時チェックが必要。

ジェネリクス
→ 型パラメータはコンパイル時だけ有効で、実行時には型消去される。
→ 不変(List<String>List<Object> のサブタイプではない)にすることで、危険な代入をそもそも禁止する。
→ 代わりに、List<String>[]new T[] のような「配列+ジェネリクス」の組み合わせは危険なので禁止されている。

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