Java では Generics クラスの配列を宣言することができません。この事実自体は割と有名なのですが、なぜ禁止されているのかについては、それほど知られてはいない気がします。
調べた結果、禁止されている理由がなかなか興味深い内容だったので、記事としてまとめておきます。
TL; DR
- 前提1:配列は共変
- 親クラスの配列に子クラスの配列を代入可能
- 前提2:Generics クラスは不変
- 親クラスの Generics クラスに子クラスの Generics クラスは代入不可
- 前提3:Generics クラスの型情報はランタイム時には削除される(型消去)
- 従って Generics クラスの問題をランタイムエラーで捕捉するのはほぼ不可能
- Generics クラスの配列を許可すると以下の自体が生じる
- 共変と不変が混ざることでコンパイル時には検知困難な問題が発生する
- そして型消去の影響でランタイム時でも検知できない
- つまりコンパイルエラーでもランタイムエラーでも抑止できない問題が生じる
前提
そもそも Generics クラスの配列が宣言できないというのは、以下のようなケースです。
Something<Object>[] somethingObjectArray = new Something<Object>[10];
上記のコードはコンパイルエラーになります。エラーの内容は以下の通りです。
Cannot create a generic array of Something<Object>
Generics クラスの配列は宣言できないよ、とまんまのことが書いてあります。
これが前提となる問題で、じゃあなぜ禁止されているのよ、というのがこれから述べる内容になります。
なお、この記事の内容は、例によって以下の記事(英語)の二番煎じの内容になります。英語を読むのが苦でない方は、そちらを読むことをオススメします。
事前準備
この問題を理解するには、以下の2つを理解しておく必要があります。
- 配列は共変で Generics クラスは不変
- Generics クラスの型情報はランタイム時には消去される(型消去)
なので、まずは上記の2つについて説明します。
配列は共変で Generics クラスは不変
まずは以下のコードをご覧ください。
Object[] objs = new Integer[10];
上記のコードは問題なくコンパイルできます。親クラスの配列には子クラスの配列を代入可能です。
一方で以下のコードはコンパイル時にエラーになります。
ArrayList<Object> objs = new ArrayList<Integer>();
Type mismatch: cannot convert from ArrayList<Integer> to ArrayList<Object>
ArrayList の場合、親クラスから子クラスへの代入はできません。これは Generics クラス自体が持つ性質なので、ArrayList 以外のどの Generics を使っても同様にエラーとなります。
上記の例で分かる通り、配列と Generics は型を引数にとって生成される型という点では同じですが、引数として渡されたクラスの親子関係をどう扱うかという点では異なっています。
共変と不変は、このような状況における親子関係の扱い方を表現する言葉です。配列のように親子関係をそのまま維持するものを 共変 と呼び、Generics のように親子関係を破棄してしまうものを 不変 と呼びます。
整理すると、
- 配列は共変
- 親クラスの配列に子クラスの配列を代入可能
- Generics クラスは不変
- 親クラスの Generics クラスに子クラスの Generics クラスは代入不可
ということになります。
Generics クラスの型情報はランタイム時には消去される(型消去)
Generics は Java 5 で紹介された機能なので、当然ながら Java 4 以前の JVM は Generics などという機能を知らない状態で実装されています。Java 4 以前のバイトコードとの互換性を保ちつつ Generics の型情報を JVM 上に載せるのは大変そうだったので、代わりに型消去(Type Erasure)という方法によって Generics が実現されました。
こちらも例を見た方が早いので、まずは以下のコードをご覧ください。
class Box<T> {
private T[] ts;
public Box(T[] ts) {
this.ts = ts;
}
public T get(int index) {
return ts[index];
}
}
class App {
public static void main(String[] args) {
String[] strings = {
"Hello World!",
};
Box<String> stringBox = new Box<>(strings);
System.out.println(stringBox.get(0));
}
}
上記のコードは型消去によって、コンパイル時に以下のように書き換えられます。
class Box {
private Object[] ts;
public Box(Object[] ts) {
this.ts = ts;
}
public Object get(int index) {
return ts[index];
}
}
class App {
public static void main(String[] args) {
String[] strings = {
"Hello World!",
};
Box stringBox = new Box(strings);
System.out.println((String)(stringBox.get(0)));
}
}
型引数が消え、型引数が宣言されていた箇所は全て Object
に変換されています。さらに取得した値を使用する箇所では型引数だった String
へのキャストが挿入されています。
大雑把ですが、上記のようにランタイム時に型情報を消去する Generics の実現方法を型消去と言います。Object
に変換すると妙な型の値が紛れ込んできそうでドキドキしますが、コンパイル時にチェックしてあるのでそのような危険はなかろう、ということのようです。
本題
ようやく本題です。なぜ Java では Generics クラスの配列は宣言が許されないのでしょうか。
結論から言うと、Generics クラスの配列が宣言できてしまうと、配列の共変を利用した抜け道ができてしまい、意味上の整合性が取れない処理がエラーなしで実行できるようになってしまうためです。
Generics クラスの配列が作成できると仮定します。つまり、
Something<Object>[] somethingObjectArray = new Something<Object>[10];
のような宣言が許可されていると仮定します。
この仮定の下で、以下のようなコードを実行することを考えてみます。
Something<String>[] somethingStringArray = new Something<String>[10];
Object[] objectArray = somethingStringArray;
objectArray[0] = new Something<Integer>(); // <- !?
1行目では Something<String>
の配列を作成しています。Generics クラスの配列宣言を解禁したので、これは問題ありません。
2行目では Object
の配列を作成し、そこに先ほど作成した Something<String>
の配列を代入しています。配列は共変なのでこれも問題ありません。
そして最終行では Object
の配列に Something<Integer>
のインスタンスを代入しています。これはいかにもマズそうな処理ですが、実はエラーになりません。Java にはコンパイルエラーとランタイムエラーの2種類のエラーがありますが、この問題の場合、どちらのエラーでも対処できない状態になってしまうのです。
まずコンパイルエラーについてですが、上記の問題を検知するには、Object[]
という型で宣言された変数の中に、実際にどの型の値が格納されているかを追跡する必要があります。が、少し検討してみるとわかるのですが、追跡を行うにはコンパイル対象のプログラムをコンパイル中に実行するしかありません。当然、コンパイル対象のプログラムが終了することは保証されませんし、色々つらいのでやめましょう、という結論に落ち着きます。
そしてランタイムエラーで対応できない理由は、型消去を適用した後のコードを見ればわかります。
Something[] somethingStringArray = new Something[10];
Object[] objectArray = somethingStringArray;
objectArray[0] = new Something();
型消去を行なった後のコードでは Something<String>
と Something<Integer>
を区別する術はありません。よってランタイムエラーではこの問題をエラーとして処理することはできません。
つまり、この問題はどのタイミングでもエラーにならず、単に「何もなかった」として見過ごされることになります。このような言語の意味論を破壊する状況を抑止するために、Java では Generics クラスの配列宣言自体を禁止しています。
この問題の根本原因について考えてみると、
- Java の言語仕様として配列を共変にしてしまったこと
- JVM に Generics のための型情報を入れないという選択をしたこと
の2つということになるでしょうか。前者は影響の予測が難しく、後者は後方互換性のためにやむを得ず、ということを考えると、歴史ある言語故のつらみを感じますね。
ちなみに、仮に Generics クラスの配列宣言を許した場合でも、代入された値を取得するタイミングでは ClassCastException
が発行されてエラーになります。
Something<String> somethingString = objectArray[0]; // ClassCastException
とはいえ、このタイミングで例外が発生したところで手遅れ感があるので、配列宣言自体を禁止するのは、それなりに妥当な判断の1つとは言えそうですね。
蛇足:型消去と Generics クラスの不変性の関連
Generics が型消去で実現されているという背景がわかると、Generics クラスが不変になっている理由も説明できます。Generics が共変だったと仮定して、以下のようなコードを考えてみます。
ArrayList<Object> objs = new ArrayList<String>(); // 仮定
objs.add(12345);
String str = objs.get(0);
2行目が見るからに地獄ですが、型消去によって add
の引数の型が Object
に変換されるため、このタイミングではエラーにはなりません。例によって、その次の行で実際に add
された値を取得する際に ClassCastException
になります。
この2行目の問題も本線の問題と同様、コンパイルエラーでもランタイムエラーでも捕捉できない類のエラーになります。つまり、そもそも Generics クラスを共変にすると、本線の問題と同様の現象が発生してしまうということですね。
そこまで知った上で考えると、やはり共変である配列と不変である Generics を混ぜるのは、そこはかとなく危険な感じがしますね。