M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

Java8の試験トピックにある「ターゲット型付け」とは何か?

・・・というわけで、にわかにJava8の新機能についてしらべはじめたのですが、アップグレード試験「Upgrade Java SE 7 to JavaSE 8 OCP Programmer」のトピックの中に「ターゲット型付け」というワードを見かけて「なんだこれは」ということになりました。

ラムダ式もStreamもトピックとしては明確なもので、つまり厳格な構文*1であったり、Javadocに示されたAPI仕様*2であったり、それらによって明確な概念的な境界線が示されています。

一方「型推論」とか「型付け」の話となるとJava独特の「曖昧さ」を想定せざるを得ません。Javaにおいて「型推論」とはまずなによりもジェネリクスに関するものであり、それがすべてです*3。そしてJavaジェネリクスコンパイル過程で型消去の対象となって最終的にJava 1.4以前から変わらない型システムのなかに溶解させられてしまいます。このジェネリクスの性格があるためにJava型推論はぎこちないものとなっていて、純粋な静的型付け言語であれば達成できたようなより徹底的な型推論の導入を妨げています。そしてコード上で何かを「省略」する場合、その結果するところを理解するには、この型消去をともなうジェネリクスの知識の土台の上に型推論の知識を重ねあわせる必要があります。

そういうわけで「ターゲット型付け」というワードを見つけて身構えざるを得なかったわけです。では「ターゲット型付け」とは何なのか? Java8の公式リファレンスにある「Javaプログラミング言語の機能拡張」を読む限りではようするに以下のようなことを指す概念のようです。

まずターゲット型。これはようするに代入時における変数の型やメソッド呼出しにおける引数の型、そしてメソッド戻り値の型です。データ(リテラルや式の結果)の型ではなく、その「容れ物」の側の型です。データの型と容れ物の型が一致しない、あるいは片方が「推論により決定できない」場合、そのコードはコンパイルエラーとなります。

例えば以下のサンプルコード(リスト1)で、1行目の代入文におけるターゲット型は変数宣言にあるList<String>です。そして3行目のCollection.addAll(...)メソッドの第1引数のターゲット型はCollection<? extends String>です。

リスト1:

List<String> stringList = new ArrayList<>();
stringList.add("A");
stringList.addAll(Arrays.asList());

List#addAll(...)メソッドListCollectionから継承しているメソッドCollection<E>においてそのシグネチャaddAll(Collection<? extends E>)です。1行目の代入式の左辺における型宣言によりEStringとなるので、メソッドシグネチャaddAll(Collection<? extends String>)となります。

あらためて言われるとにわかに信じがたいことですが、このサンプルコード(リスト1)の3行目はJava7以前であればコンパイル不可能でした*4Arrays.asList(...)メソッドの呼出しが、仮にArrays.asList("B", "C")などであれば、その引数の型からして戻り値のデータ型も自ずと決まります。ところが引数を伴わないArrays.asList()メソッド呼出し(可変長引数の仕組みにより実際には空の配列が生成され引数に設定されます)の結果の型として何が返されるのか、この点をコンパイラは判断できない、つまりメソッド呼び出しという式を評価した結果のデータ型が不明の状態となります。コンパイラにとってターゲット型は明白ですが、それに対応するデータの型がわからないのです。

Java7でも以下のサンプルコード(リスト2)であればコンパイル可能でした。3行目の代入の際、ターゲット型(左辺の変数宣言の型)は明確であり、そこからコンパイラArrays.asList()の呼出し結果のデータ型を推論できたのです。その後の4行目では、ターゲット型も対応するデータ型も明確になっているので、このメソッド呼び出しもコンパイルできます。つまり代入時と引数設定時では異なるレベルの型推論が行われていたのです。

リスト2:

List<String> stringList = new ArrayList<>();
stringList.add("A");
List<String> stringList2 = Arrays.asList();
stringList.addAll(stringList2);

あるいは以下(リスト3)のようにメソッドレベルの型パラメータを明示的に指定しても同じことです。この場合はメソッドレベルの型パラメータを用いることで、Arrays.asList(...)の引数の型が確定し、それにともない戻り値の型も確定します*5。このコードはコンパイルできます。

リスト3:

List<String> stringList = new ArrayList<>();
stringList.add("A");
stringList.addAll(Arrays.<String>asList());

これに対して、Java8では先ほどのようなケース(リスト1)でも型推論が機能するようになりました。引数を伴わないArrays.asList(...)メソッド呼出し式のデータ型は、そのターゲット型から逆算してList<String>であると、コンパイラによって結論付けられます。そうであるならターゲット型も対応するデータ型も明確であり、二者の間に齟齬はないのでコンパイルは成功します。

以上の例は上述の公式資料において「もっとも顕著な例は・・・」として挙げられているもので、可能な(可能になった)すべてのケースを列挙しているわけではありません。それでは他にどのような状況があるのでしょうか。これはどこかにまとまった資料があるのかもしれませんが(おそらくJLSにはこの点についてもっと明確な記載がありそうです)、手元でためしにコーディングしてみた結果としては以下のようになりました(リスト4)。

リスト4:

public List<String> method1() {
	return Collections.emptyList();
}

public List<String> method2(boolean cond) {
	return cond ? Arrays.asList() : Collections.emptyList();
}

public Iterator<String> method3() {
	return Arrays.<String>asList().iterator();
}

public Iterator<String> method4() {
	return method4Helper(Arrays.<String>asList()).iterator();
}

private<T> T method4Helper(T t) {
	return t;
}

method1はJava7以前でも問題なくコンパイルできました。戻り値のターゲット型からメソッド呼出し式のデータ型が推論できていたわけです。代入の場合と同様です。一方method2はJava7以前であればArrays.asList(...)にもCollections.emptyList()にもメソッドレベルの型パラメータが必要でした。どちらか一方が欠けてもコンパイルはできません。Java8ではこれが可能になっています。

method3method4ではArrays.asList(...)メソッドレベルの型パラメータが使用されています。Java8であってもこれらの指定は不可欠です。仮にこれらを推論するとしたら、必要になるのは「引数や戻り値のターゲット型」の推論機能ではなく、「引数や戻り値を設定するメソッド呼び出し式のレシーバのターゲット型」の推論機能ということになって、一段と複雑な話になってきます。いずれにしてもJava8でもここまではできません。

以上で見てきたターゲット型の決定やそのための推論を「ターゲット型付け」と呼ぶようですが、サンプルコードで示した例では所詮型消去で消されるべき情報に関するものでした。

一方これがラムダ式に適用されるとちょっと次元の違う話になってきます。なぜならそのターゲット型付けの結果として「当該のラムダ式をもとにどの関数型インターフェースが必要とされているか」が決まり、「どの関数型インターフェースを実装した匿名インナークラスを宣言するか」(もちろんこのインナークラスはコンパイラが自動的に宣言するものなのでコード上には現れません)が決まるからです。ジェネリクスの型パラメータ情報は除去されても、インターフェースとその実装であるインナークラスそのものは除去されません。そこらへんの事情についてはこちらのページ(訳出はこちら)に示唆されているようです。

m12i.hatenablog.com

*1:可読性の観点で曖昧さがないということではないですが、何が許されて何が許されないかは明確になっています。

*2:ラムダとStreamの導入によりJavaの標準APIはまたしても大きく膨張しました。これはファイルサイズうんぬんの話ではなく「概念的な重み」の話です。

*3:ラムダ式における仮引数の型名の省略すらその範疇の中にあります。ラムダ式の仮引数は関数型インターフェースにおいてジェネリクスで表現されています。

*4:そして1行目の代入文もJava6以前であればコンパイル不可能でした。それがJava7で可能になったわけですが、単にnew ArrayList()と記述するとこれは型推論できなくなりコンパイラ警告の対象となるというところが、型消去をともなうジェネリクスに由来する厄介さの一端を示しています。

*5:つまりArrays.<T>asList(T...)はArrays.<String>asList(String...)となり、これにともない戻り値であるList<T>はList<String>となるので、データ型が明確になります。