テーマの全体像を先にまとめる
ジェネリクス(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> → 単なる ListList<Integer> → 単なる List
としてしか存在しません。
だからこそ、instanceof List<String> が書けなかったり、new T[] ができなかったりするのですが、それは後で整理します。
配列は共変(covariant)、ジェネリクスは不変(invariant)
配列の共変性:サブクラス配列をスーパークラス配列に代入できる
配列には「共変性」という性質があります。
難しい言葉ですが、やっていることはシンプルで、
String は Object のサブクラスだから、String[] は Object[] のサブタイプとして扱う
というルールです。
さっきの例がまさにそれです。
String[] strings = new String[10];
Object[] objects = strings; // OK(共変性)
Javaこの代入はコンパイルも通りますし、実行時にも動きます。
ただし、実行時の型チェック(ArrayStoreException の仕組み)で
「おかしなものを詰められる」のを防いでいる、という状態です。
ジェネリクスの不変性:List<String> は List<Object> ではない
ジェネリクスは基本「不変(invariant)」です。
String は Object のサブクラスだけれど、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[] は OKList<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[] のような「配列+ジェネリクス」の組み合わせは危険なので禁止されている。
