ジェネリクスと配列

Javaジェネリクスは一般に配列と混ぜてはいけないとされるが、混ぜて用いた場合に何が問題となるのか。

歴史的な問題

Javaが1995年に登場した当時、Javaに配列はあったがジェネリクスはなかった。

ジェネリクスを含む型システムの理論的な整備は、1990年代から2000年代にかけてのJavaのバージョンアップの時期に並行して行われていた。これは1995年当初のJavaになぜより良いジェネリクスを搭載した形でリリースされなかったのか?ということにひとつの答えを示すだろう。つまり、1995年当時にはジェネリクス(Java5に搭載されたような変性を含むもの)は未来の技術であって、まだ理論的に固まっていないものであった、というわけだ。

Java言語仕様にも記述されているが

Historically, wildcards are a direct descendant of the work by Atsushi Igarashi and Mirko Viroli. Readers interested in a more comprehensive discussion should refer to On Variance-Based Subtyping for Parametric Types by Atsushi Igarashi and Mirko Viroli, in the Proceedings of the 16th European Conference on Object Oriented Programming (ECOOP 2002). This work itself builds upon earlier work by Kresten Thorup and Mads Torgersen (Unifying Genericity, ECOOP 99), as well as a long tradition of work on declaration based variance that goes back to Pierre America's work on POOL (OOPSLA 89).

Javaジェネリクスに現れるワイルドカード、そしてそれによって表現される変性については2002年のAtsushi Igarashi と Mirko Viroliの論文が元になっている。なお、Java5 がリリースされたのは2004年9月30日のことであった。

Javaの配列にはC言語の配列の影響が色濃く見える。配列を表す[]が型の側にも変数の側にもつけて宣言できるのもその影響の一端であろう。

String[] array1;
String array2[];

配列のString型はString型ではない。StringはStringを間接的に扱う。型を間接的に扱うのだから、ジェネリクス的である。C言語の配列が作られたとき、配列がジェネリクスであるという理解でもって設計されたわけではなかろう。しかし、現代から振り返ってみれば、配列は言語に組み込みの機能を限定したジェネリクスである、といえる。

配列はジェネリクスのようなもの

Javaでは参照型は親の型の変数に子の型を代入することはキャストなしに行うことができる。

Object o = new String("hoge");

これはリスコフの置換原則(Liskov, Barbara; Wing, Jeannette 1993年7月16日 Family Values: A Behavioral Notion of Subtyping)として知られる。

ごく端的に言えば、子は親の機能を代替できなくてはならない。代替できる前提において、子を親の型扱いすることができる。こうした変数の型に対して子や孫の型を代入できる関係を「共変性」(covariance)という。Javaの型システムの根幹をなす重要な概念だ。

しかし、型を間接的に用いるジェネリクスでは、変数を単純に共変とすることができない。

ArrayList<String> stringList = new ArrayList<String>();
ArrayList<Object> objectList = new ArrayList<Object>();
objectList = stringList; // コンパイルエラー

なぜか。

objectList = stringList; // 仮に出来たとする
objectList.add(new Object()); // stringList にObjectが格納される

ArrayList

  • get すると Object が返る
  • Object を add できる

ArrayList

  • get すると String が返る
  • String を add できる

ArrayListはgetするとStringを返すわけだが、これはObjectであるわけで、ArrayListのgetの機能を満たす。しかし、ArrayListはStringしかaddすることができず、Objectをaddすることができない。

つまり、ArrayListArrayListが提供する機能をすべて代替できていない。なので子になることはできない。このため、ArrayList<String>のようなパラメタライズドタイプ(parameterized type)は通常の変数の共変性とは異なり非変性とされているのである。Java5でジェネリクスが導入されたことにより、Java言語は変数の変性について複雑さを増した。

「型を間接的に用いるジェネリクスでは、変数を単純に共変とすることができない」と言った理由はここにある。そして配列もまたジェネリクスのようなものであった。なので同じ話題がある。

String[] stringArray = new String[10];
Object[] objectArray = new Object[10];
objectArray = stringArray; // コンパイルエラーにはならない
objectArray[0] = new Object();

上記コードはコンパイルエラーにはならないが、実行すると以下のように例外が出る

Exception in thread "main" java.lang.ArrayStoreException: java.lang.Object

この例外については、出ることが保証されており、1995年当時にJavaの実装者がこの問題を知らなかったわけではない。

しかし、ジェネリクスの変性についての論文が2002年なのであって、1995年当時のJavaに、配列型の変数にだけ共変性を持たせないという言語仕様を求めるのは時系列的に無茶というものだろう。

かくしてJavaの変数は、通常の参照型も配列型も共変性となった。(さらに言えば配列型もObject型に代入可能だったりするのでなお配列型だけ共変としないというわけにはいかなかったのだろう)
Javaの型システムはこの点で欠陥をはらんでいるのである。

配列の構文

枕の話が長くなったが、配列というのは間接的に型を扱うものであるからして、ジェネリクス的なものである。しかし、時系列的な都合もあり、Javaの配列はジェネリクスに統合されていない。Javaの型システムには配列とジェネリクスという似たような別物が含まれることとなった。より後発のScalaなどでは配列はジェネリクスに統合されている。

というわけで、Javaではジェネリクスと配列を混ぜて使うとよろしくない。しかし、じゃあ具体的にどうよろしくないのだろうか?筆者も「混ぜて使うな!」までで話を終わらせてしまってばかりで具体的にどうよろしくないか、までは深入りしたことはなかった。本稿はそれを探るというもので、実用上は「混ぜて使うな!」で十分である :-P

まず、JavaのList<String>のようなパラメタライズドタイプ(parameterized type)は配列にして変数宣言することができる。

List<String>[] listStringArray;

このあたりの根拠をJava言語仕様から探すとなかなか大変なのだが、概ね 8.3. Field Declarationsのあたりに記載があって、大雑把に構文を抜粋すると

FieldDeclaration:
 {FieldModifier} UnannType VariableDeclaratorList ;


UnannType:
 UnannPrimitiveType
 UnannReferenceType


UnannReferenceType:
 UnannClassOrInterfaceType
 UnannTypeVariable
 UnannArrayType


UnannClassOrInterfaceType:
 UnannClassType
 UnannInterfaceType


UnannClassType:
 Identifier [TypeArguments]
 UnannClassOrInterfaceType . {Annotation} Identifier [TypeArguments]


UnannArrayType:
 UnannPrimitiveType Dims
 UnannClassOrInterfaceType Dims
 UnannTypeVariable Dims


Dims:
 {Annotation} [ ] {{Annotation} [ ]}

要するに、UnannArrayType つまり配列は UnannClassOrInterfaceType (クラスorインターフェース)に [] をつけることが許されていて、UnannClassOrInterfaceType は UnannClassType (クラス)とかで、UnannClassType は
Identifier [TypeArguments] と識別子に加え型引数をつけることが許されている、つまり、パラメタライズドタイプを配列にして変数宣言することが構文上できる。[ ]は任意の意味合いだ。

ところが、配列をnewするときの右辺側が問題である。

FieldDeclaration:
 {FieldModifier} UnannType VariableDeclaratorList ;


VariableDeclaratorList:
 VariableDeclarator {, VariableDeclarator}


VariableDeclarator:
 VariableDeclaratorId [= VariableInitializer]

この = VariableInitializer の部分が変数初期化子で

VariableInitializer:
 Expression
 ArrayInitializer

Expression つまり式か、ArrayInitializer 配列初期化子を置くことができる。
まずはExpressionから見てみよう。15.8. Primary Expressionsに定義が載っていて

Primary:
 PrimaryNoNewArray
 ArrayCreationExpression

ここでは ArrayCreationExpression 配列生成式を見ていく。15.10.1. Array Creation Expressionsが該当の節だ。

ArrayCreationExpression:
 new PrimitiveType DimExprs [Dims]
 new ClassOrInterfaceType DimExprs [Dims]
 new PrimitiveType Dims ArrayInitializer
 new ClassOrInterfaceType Dims ArrayInitializer


DimExprs:
 DimExpr {DimExpr}


DimExpr:
 {Annotation} [ Expression ]

ここで、単に new ClassOrInterfaceType DimExprs [Dims] とあるので、パラメタライズドタイプの配列も宣言できそうに見えるが、注釈がついている。

The rules above imply that the element type in an array creation expression cannot be a parameterized type, unless all type arguments to the parameterized type are unbounded wildcards.

パラメタライズドタイプは駄目、型変数は駄目、ワイルドカードは駄目、というわけである。つまり、これらはnew で配列を生成できない。

2018.02.15 追記

指摘いただいて気付いたが盛大に誤訳していた。「全ての型変数が境界を持たないワイルドカードでない限りnewできない」と訳すべき内容で、例えば

ArrayList<?>[] list = new ArrayList<?>[10];

といったコードはコンパイル可能。これは恐らくJava5以前のraw型において

ArrayList[] list = new ArrayList[10];

が可能なことに対応付けられる措置であると思う。

追記ここまで


というわけでVariableInitializerの定義まで戻って

VariableInitializer:
 Expression
 ArrayInitializer

今度は ArrayInitializer について見ていこう。10.6. Array Initializers

ArrayInitializer:
 { [VariableInitializerList] [,] }


VariableInitializerList:
 VariableInitializer {, VariableInitializer}


VariableInitializer:
 Expression
 ArrayInitializer

Javaの構文の細かい所ではあるが、配列の初期化にはいくつか方法があって

String[] array = new String[10];

といった場合は先の ArrayCreationExpression 配列生成式となる。ArrayInitializer 配列初期化子は

String[] array = {"hoge", "piyo"};

といった場合の {} の部分で、これは変数宣言時のみ使用可能で、一般的な式としては用いることができない。
さてこの配列生成式だが、この節にはパラメタライズドタイプの配列生成式が駄目ということは書かれていない。しかし、冒頭に

An array initializer may be specified in a field declaration (§8.3, §9.3) or local variable declaration (§14.4), or as part of an array creation expression (§15.10.1), to create an array and provide some initial values.

といった記述があり、先のArrayCreationExpression 配列生成式などの一部として用いるものである、とあるのでそちらに準じるのだろうか。

言語仕様上、ArrayInitializer 配列初期化子でパラメタライズドタイプが用いれないことについては根拠がはっきりしなかったが、Oracle JDK 9.0.4 を用いて確認を行ったがコンパイルエラーとなることは確認できた。言語仕様の記述の対応を探すのは今後の課題である。


なお、配列生成式でパラメタライズドタイプの配列を作ろうとした場合は以下のようなコンパイルエラーが出る。参考まで。

Hoge.java:44: エラー: 汎用配列を作成します
List[] listStringArray = {};

パラメタライズドタイプの配列生成方法

こうしたことから、パラメタライズドタイプの配列は変数型としては宣言できるものの、配列のインスタンス生成は行えないように見える。しかし、実際にはインスタンス生成をする抜け道がひとつある。可変長引数である。

static <T> T[] toArray(final T ... ts) {return ts;}

このようなメソッドを宣言して

List<String> list = new ArrayList<>();
list.add("hoge");
List<String>[] listStringArray = toArray(list, list, list);

System.out.println(listStringArray.length);
System.out.println(listStringArray[0]);

このように用いるとパラメタライズドタイプの配列インスタンスを作ることができる。

配列の共変性に関わる問題

さて、Javaの配列は歴史的経緯から共変な変数であることは冒頭述べた。
パラメタライズドタイプの場合、これはどうなるか。

List<String>[] listStringArray = toArray();
List<Object>[] listObjectArray = toArray();
listObjectArray = listStringArray; // コンパイルエラー

これは要するに List<Object> に List<String> が代入できないため、 List<Object> にも List<String>が代入できないのである。これだと一見うまくいっているように見える。しかし

List<String>[] listStringArray = toArray(new ArrayList<String>());
List<? extends Object>[] listObjectArray = toArray(new ArrayList<Object>());
listObjectArray = listStringArray; // OK
listObjectArray[0] = new ArrayList<Object>();

List<String>を代入可能なList<? extends Object>の配列を用意すると、この代入可能に引きずられてList<? extends Object>にList<String>が代入できてしまう。ここまでは配列と同等なのだが、イレイジャの都合もあって実行時にArrayStoreExceptionが出ない。

とはいえ、List<? extends Object>であるためadd(new Object());はコンパイルエラーとなる(この理由は拙稿 ジェネリクスの代入互換のカラクリ あたりを参照して欲しい。ワイルドカードは入出力を制限することで共変性や反変性をもたせることが出来る)

listObjectArray[0].add(new Object()); // コンパイルエラー

基本的に、配列の共変性を引きずるので、X に Y が代入可能なら X に Yが代入可能と判断される。
型変数の場合は境界によって代入可能となっていれば同じように配列も代入可能となる。

static <T1, T2 extends T1> void foo() {
	T1[] t1a = null;
	T2[] t2a = null;
	t1a = t2a;
}

特に使い道は思いつかないが。

追記 配列をバインド

逆パターンを書き忘れていたので追記。

型変数に配列をバインドすることはできるか? できるのである。

List<int[]> x = new ArrayList<int[]>();
x.add(new int[] {1, 2, 3});
int[] intArray = x.get(0);

Java9時点では型変数にプリミティブ型をバインドすることは出来ないが、int[]は配列なので参照型である。参照型だからバインドすることができる。

しかし、配列の共変性は相変わらず問題になる。

List<Object[]> y1 = new ArrayList<Object[]>();
List<String[]> y2 = new ArrayList<String[]>();
y1 = y2; // NG
y1.add(new String[] {});

y1 = y2 はパラメタライズドタイプの非変性によってコンパイルエラーとなる。しかし、y1.add(new String[] {})は配列の共変性によりコンパイルエラーとはならない。

むしろプリミティブ型配列であれば共変性の問題が無い分だけ安全かもしれない。共変性が問題となるケースに気をつければ挙動自体には問題はない。どうしても使いたいケースが生じた場合は十分に注意されたし。

まとめ

  • 配列は言語組み込みの機能限定版ジェネリクスのようなもの
  • Javaは後付けでジェネリクスを追加した際、配列をジェネリクスに統合できなかった
  • そのため配列とジェネリクスは似て非なるものとなり相性が悪い
  • パラメタライズドタイプの配列や、型変数の配列、ワイルドカードの配列などは変数型として宣言可能である
  • ただし、基本的にそれらの配列のインスタンスを生成することはできない
  • 配列の共変性の問題がこれらの配列でも生じる
  • イレイジャの都合でArrayStoreExceptionが出ない
  • 基本的に使わないべき

言語仕様から感じられることとしては、パラメタライズドタイプの配列や、型変数の配列、ワイルドカードの配列は基本的に使わせたくないのだろう。