贖罪のイレイジャ

Javaジェネリクスでしばしば話題に上がる「イレイジャ」について整理しておきたい。

イレイジャについては僕もいろいろと誤解しており、過去に誤った発言をしている。本エントリはその贖罪として書かれたものである。

「イレイジャ」という方式についてはネガティブな誤解が広まっていると思う。「イレイジャ方式」が問題の根ではない事象について、それを「イレイジャのせい」であると誤って理解することはエンジニアとしてはマイナスである。

しばしばイレイジャのせいとされる事象にnew T()できないという論点があるが、これはJavaジェネリクスC#でいうnew制約(型変数の制約としてデフォルトコンストラクタを持つことを要求する機能)を持たないことに起因する問題である。

そのため、この点についてJavaの言語仕様に改善を求めるのであれば、new制約を導入せよという現実的な要求とするべきである。

イレイジャ方式を採用したのは歴史上、Javaが初めてというわけでもなく、Java VM .NET の IL がよく対比されるのは Java / .NET がそれだけ多く利用されているからなのだろう。

イレイジャの定義

まず「イレイジャ」(type erasuer:型消去)という用語の定義を確認したい。おそらく、多くの方のイメージとは食い違うことだろう。とにかく名前から「消える」のニュアンスが強すぎるきらいがある。

Java言語仕様より抜粋する。

(日本語訳を引用するため古いJava5時代の言語仕様から引用している。以後の言語仕様は日本語化されていない。本稿執筆時点最新のJava8のものはThe Java® Language Specification)

4.6 型のイレイジャ
型のイレイジャ(type erasuer:型消去)とは,型(パラメータ化型や型変数を含む)から型(パラメータ化型や型変数を含まない)への対応付けである。型Tのイレイジャは|T|と表記される。イレイジャの対応付けは以下のように定義される。

・パラメータ化型(§4.5) Gのイレイジャは|G|である。
・ネストされた型T.Cのイレイジャは|T|.Cである。
・配列型Tのイレイジャは|T|である。
・型変数(§4.4)のイレイジャは,その最も左端の境界におけるイレイジャである。
・その他の型すべてのイレイジャは,その型自身である。

メソッド・シグネチャsのイレイジャは,sと同じ名前,およびs中で指定されたすべての形式的パラメータ型のイレイジャからなるシグネチャとなる。

主にシグネチャについての話なのである。

イレイジャの理解

イレイジャを理解するために、類似性のあるものとしてメソッドのオーバーライド / オーバーロードを例に挙げよう。

Javaのメソッドは「メソッド名」と「引数の型」によって特定される。

プログラミング言語の種類によってはメソッドを「メソッド名」のみで特定して用いる言語もあり、その場合は同名で引数の型違いのメソッドを扱うことができない。

Javaの型(class / interface)は「型名」によって特定される(より正確にはパッケージ名を含む完全名のことである)。同名で型変数違いの型を扱うことはできない。

その「型名」をより厳密に定義したものが先に挙げた「イレイジャ」であり、

型Tのイレイジャは|T|と表記される

に従えば、例えばjava.util.ArrayList<String>のイレイジャは|java.util.ArrayList|であり、java.util.ArrayList<Integer>のイレイジャもまた|java.util.ArrayList|である。

Java5以前であれば「型」を特定するのに型の完全名さえあれば十分であった。Java5によってジェネリクスが導入されるとそれまでの「型」に「型変数」という要素が増えた。

この時に「型」+「型変数」で特定するような変更を行わなかった。「型」+「型変数」に対応づく「型(パラメータ化型や型変数を含まない)」つまり「イレイジャ」を使って特定することとし、既存の機能との互換性を保つ方針としたわけである。|java.util.ArrayList|で解決するわけだ。

これはメソッドのシグネチャについても波及して、hoge(List<String> list)とhoge(List<Integer> list)というようなオーバーロード定義ができないことに繋がる。いずれのイレイジャも|java.util.List|であるから。

classファイルに残されるジェネリクス

パラメタライズド・タイプを削ったシグネチャを「イレイジャ」と呼び、「イレイジャ」で型やメソッドを特定する(コンパイラの挙動からすると「解決する」という用語が適切か)わけだが、かといってclassファイルには「イレイジャ」だけが記録されていると考えるのは誤りだ。

classファイルのフィールドやメソッドシグネチャにはパラメタライズド・タイプなどの情報が残されている。以下はThe Java® Virtual Machine Specification Java SE 8 Editionからの引用である。(Java1.2のものまで日本語化されているのだが Amazon CAPTCHA 以降は日本語訳書は出ていない)

ClassSignature:
[TypeParameters] SuperclassSignature {SuperinterfaceSignature}
TypeParameters:< TypeParameter {TypeParameter} >

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.9.1

なぜclassファイルにパラメタライズド・タイプのようなジェネリクスの情報が残されているのか。それはclassファイルだけが提供されているライブラリを利用するJavaコードを書いた際もまたジェネリクスによってコンパイラが型安全を担保するために必要だからである。

classファイルにする段階でメソッドhoge(List<String> list)をイレイジャにしてhoge(List list)にしてしまっていたら、このclassファイルを参照するjavaファイルをコンパイルするにあたって

List<Integer> list = new ArrayList<>();
hoge(list); // List<String>じゃないとダメ

コンパイルエラーにできないではないか!

JavaのリフレクションでもまたClassやMethodからパラメタライズド・タイプを含む情報を取得することもできる。

イレイジャ方式というのは、例えるなら実行時にメソッド名+引数の型でメソッドを解決するのではなくメソッド名だけで解決するようなものだ。

そして、classファイルにはパラメタライズド・タイプなどの情報がしっかりと記録されている。そういうものなのである。メソッド名の例でいえば、メソッド名だけで解決する言語があったとしても引数の型の情報を残せないわけではない、ということだ。

イレイジャ方式というのはシグネチャの解決の話であるのだが、javaファイルがコンパイラによってclassファイルになる時点でシグネチャの型が消されることはない。

実行時のイレイジャ

Javaは実行時に用いるjava.lang.Classの情報をパラメタライズド・タイプ毎に持たない。ClassLoaderがClassを読み込む際には代表してイレイジャが用いられる。

そのため、パラメタライズド・タイプを多用してもClassが大量に読み込まれてMetaspace(Java7までだとPermanent領域)が圧迫される心配はない。

しかし同時に、実行時にObject#getClass()で得られるjava.lang.Classもイレイジャなのである。VM内でClassのロードやメソッドシグネチャの解決をするにはイレイジャがあれば十分なので、getClass()の機能性もJava1.4まで同等としたのだろう。

この点が語感もあって「イレイジャ」の本質であると誤解している人が多いのではないか。

実行時に消すから「イレイジャ」なのではなく、シグネチャ解決のためにイレイジャを用いるから動的に持ちまわっている情報もイレイジャまでで済ませている、ということである。

まぁ腐して言う人にしてみれば理由はなんであれ実行時に型が取れなければ一緒だろう :-P

イレイジャ方式のScala

Java VM 上で動くJavaより後の世代の言語に Scala が挙げられる。Scalaもまたイレイジャ方式のメソッド解決をする言語である。

Javaと同様にイレイジャが同一となるメソッドのオーバーロードができないという制約を持つ。

しかし、TypeTag / ClassTagを用いて実行時に型変数の型を得ることができる。つまりイレイジャ方式であっても、実行時に型変数の型を動的に得られるようにすることは可能である

Javaが実行時に型変数の型を動的に得られないのは、実行時のイレイジャに対して、別途、型変数の型を渡す機構を作らなかったからと言えよう。現に、別途作ったScalaはやれているのだから。

実行時の型変数

実行時に動的に型変数の値を得られるようにするにはいくつかの方法論が考えられる。ジェネリクスの実装方式も含めていくつか挙げると

  • シグネチャの解決に型変数も利用するようにする (非イレイジャ)
  • コンパイル時に型のぶんだけコードの複製を作る (テンプレート方式)
  • メソッド呼び出しに際してコンパイラが暗黙の変数を渡す (Scala方式)

これらはどれが一概に優れているとは言えない。いずれの方式でも相応の実用性を得ることができるだろうし、それぞれデメリットもある。

Javaジェネリクスを導入するにあたって互換性を考慮してイレイジャ方式を採用したため、実行時に動的に型変数の値を得られるようにするには、必然的にScalaのような方式を取らざるをえないのであるが。

ただ、そもそも、ジェネリクスを静的な型チェックに重きをおくならば、実行時に動的に得る機能性というコードを書くことは下策なのである。だが、時にこの抜け道が役に立つ時がある。しかしこれはあくまでも抜け道であって、抜け道があることをジェネリクスの本質なのだ、などという主張は説得力に欠ける。

イレイジャ方式のメリット

イレイジャ方式のメリットとして挙げられるものは

  • ジェネリクス対応前のコードとの互換性
  • VM上でより拡張された型システムの実装を設計する際の容易さ

があるだろう。

既存のVMに乗っかる形での言語拡張は、どうしてもその土台の言語のランタイムに引きずられる部分がある。型システムをより先進的なものに拡張しようとした場合、土台の言語のランタイムの型システム上にのせる必要がある。

この時、ランタイムが型+型変数(ただし土台の言語の型システムの型)にて解決する仕様だとすれば、より先進的な型を土台の言語のより古い型+型変数に対応付けなくてはならない。これは正により先進的な型システムから型情報を消し去る対応付けなわけで、イレイジャである。そして、完全なイレイジャではなく、土台の言語の型システムの型へと対応付ける部分欠損するイレイジャということになる。この部分欠損するイレイジャは、完全欠損するイレイジャに比べ扱いが面倒くさいものになることが想像できることだろう。

つまり、土台を変えない前提ならイレイジャとせざるを得ないのである。そして不完全なイレイジャが面倒ごとを持ってくることは容易に想像できるだろう。そのときにランタイムを一新する痛みを我慢できるかどうか、である。

高階

イレイジャと似て非なるものとしては、型の高階が挙げられるだろう。

この時、java.util.ArrayList<String>java.util.ArrayList<Integer>のイレイジャの|java.util.ArrayList|のようなものが必要になってくる。そしてこれはraw型とはまた違うものである。

結局のところ、Javaの世代の型システムは型の表現力が乏しいのである。

ジェネリクスのない時代は型の概念がシンプルだったが、型変数という「関節」のようなものが入ると、求められる型の表現が多様化し複雑化し、随分と難しくなってしまった。型クラスのような高階をやりだすとなおのことである。

まとめ

イレイジャ方式では、同名の型変数違いのclassを定義することはできないし、同名で引数のパラメタライズド・タイプの型が違うだけのメソッドオーバーロードをすることもできない。

これは正にイレイジャ方式を採用したが故の直接的な弊害である。

classファイルはパラメタライズド・タイプなど、ジェネリクスの情報はちゃんと保持している。保持しているからclassファイルだけが提供されているライブラリであってもコンパイラジェネリックなメソッド呼び出しが安全かチェックしてくれる。

型変数にバインドされた型を動的に得ることのできるScalaのTypeTag / ClassTagのような機能性がJavaには提供されていないが、イレイジャ方式であるJava VM上で実装できないという意味ではない。

また、実行時にnew T()したいという要求の多くはC#のnew制約(該当の型がデフォルトコンストラクタを持つことを要求する制約)がないことに起因する。メソッド内でのインスタンス生成がしたいケースでは、Java8の現代では引数にSupplier<T>をとり、ラムダ式ないしメソッド参照を用いるのが柔軟性が高くシンプルで使い勝手が良いだろう。

この方式については過去に拙稿Java8流インスタンスを生成するメソッドの書き方 - プログラマーの脳みそで触れた。

しかしO/Rマッパーのようなフレームワークを作るケースではSupplier<T>方式が不便なときもあるだろう。こうしたケースではTypeTag / ClassTagのような機能性が求められることだろう。