再帰ジェネリクスのthisとTの互換性

再帰ジェネリクスを用いて以下のようなコードを書いたとする。
Hogeを継承した型を作った場合に具象型を得るgetThis()メソッドを使えるようにしたいわけだ。

public class Hoge<T extends Hoge<T>> {
	@SuppressWarnings("unchecked")
	public T getThis() {
		return (T) this;
	}
}

このHoge型のTに型をバインドするには継承を用いる。一部の方にはおなじみですね。

public class Piyo extends Hoge<Piyo> {}

こうした場合、Piyo型にはなんの実装も行っていないが

Piyo p = new Piyo();
Piyo t = piyo.getThis();

というように、スーパークラスの実装によりgetThis()からPiyo型をとることができる。

型の安全性に対する議論

この例では@SuppressWarnings("unchecked")しているわけだが、thisをTに安全にキャストできないのはなぜか。

T は extends Hoge なのであるから、Tにバインドされる型というのは、Hoge型かサブクラスのPiyo型か、まだ産まれてきていない未知のサブクラスかもしれない。

対してthisは宣言上はHoge型である。しかしてその実体は具象型のインスタンスに依るのでPiyo型であったり、未だに産まれぬ型を継ぐものかもしれない。ただ、少なくとも型としてはHoge型なのである。だから、T型へのキャストというのはダウンキャストとなる。

いやいや。Hoge型みたいに再帰ジェネリクスな型変数持ってるとどうせ型安全にnewできないでしょ。thisってのは実質的にHoge型には成り得ないわけで、T もextends Hogeなわけなんだから、型安全にキャストさてくれてもいいじゃない!?…と思うのが浅はかなのである :-P

型Tがサブクラスのthisとならない事例1

その反例というのは

public class Foo extends Hoge<Piyo> {}

といった存在である。再帰ジェネリクスって継承するときに自分自身の型をバインドするもんじゃないの?と言われると、そういう使い方を想定したものではあるんだけど、型システム的にはこういうのが許容されてしまうわけで、このケースだと this と T は完全に互換性がない。

ジェネリクスの型からこのFoo型はgetThis()でPiyo型を返す。まぁTにPiyo型をバインドしたのだから、T型をreturnするgetThis()の戻り値の型は当然Piyo型になるわけで。

Foo foo = new Foo();
Piyo piyo = foo.getThis();

しかし、getThis()の実装というのは

public T getThis() {
	return (T) this;
}

なのであるから、実際にreturnされるのはthis、つまりfooのインスタンスなのである。しかもこれ、return (T) this;のところでClassCastExceptionになるわけではない。なぜそこでキャストエラーにならないか、と言われるとJavaジェネリクスはイレイジャ方式だからって話なんだけど。

だから、foo.getThis()をするだけでPiyo型変数への代入をしなければエラーにならずに済む。ならずに済むというか、検出漏れしてろくなことにならないっていう話。

型Tがサブクラスのthisとならない事例2

さて、キャストエラーとなる反例なのだけど、もうひとつある。

Hoge<Piyo> hoge = new Hoge<Piyo>();

再帰ジェネリクスだとnewではうまくバインドできません、みたいなことを以前言ったような気がするけど、ごめんなさい。実はできます。もっともそのためにはすでに継承でバインド済みの型Piyoが必要なので、再帰ジェネリクスをバインドしたければ継承しろってのは基本的には変わりませんけどね。

この場合は

Piyo piyo = hoge.getThis();

となるのだけど、thisはHoge型なので、やはりPiyo型にキャストできなくてClassCastExceptionが発生する。

ただし、Hoge型はそもそも継承を想定して作られているのだから、このパターンというのはHoge型をabstract宣言すれば防げる。



型Tがサブクラスのthisとならない事例3

ここからはキャストエラーにはならない事例。
再帰ジェネリクスの型変数Tはサブクラスにおいてthisの型と等価かというとそうならない場合があるよ、ということで留意しておきたい。

public class ExPiyo extends Piyo {}

Piyo型のような再帰ジェネリクス2世にばかり思いを巡らせて3世を忘れがちだが、この3世は結構厄介なときがある。

再帰的ジェネリクスの代入互換性 - プログラマーの脳みそでは再帰ジェネリクスな型を型変数として扱うクラスFooを作るときに3世にやられる話を書いた。
結論からすれば

public class Foo3<X extends Hoge<? super X>> {}

としてね、って話なんだけど、このように2世までとは違った複雑さが出てくるので油断ならない。

いささか脱線したか。

ExPiyo上におけるgetThis()は戻り値がPiyo型となる。

Piyo piyo = getThis();

ここでgetThis()が返すのはExPiyoのインスタンスなわけだが、ExPiyoはPiyo型を継承しているのだからキャストエラーとはならない。セーフ。

ただし、ExPiyo型で得られないということはExPiyo型に宣言された各種フィールドやメソッドにアクセスできないので再帰ジェネリクスでダウンキャストしないで具象型をとれて便利に使えるぜー!と思ったら、あれ?俺なんでダウンキャストしてるんだ?ってなりかねない。

型Tがサブクラスのthisとならない事例4

まだあんのかよ。あるんです。

public class Bar<T2 extends Hoge<T2>> extends Hoge<T2> {}

先程は2世で継承バインドしてその3世を作ったわけだけど、こちらは2世でまた型変数T2を宣言してバインドを3世以降に先送りしようというパターン。

この場合、getThis()の静的な戻り値はT2型とされる。このT2はそれこそHogeにバインドする型そのものなので、getThis()の戻り値と静的には互換性があることになる。

T2 t2 = getThis();

このBar型の時点ではHogeのT型というのはBarで宣言されたT2に置き換わっているとはいえ、型変数のまま。このBarを事例1とか事例2みたいに扱えばやはりキャストで失敗するが、このBarの存在自体がキャストエラーを引き起こすわけではない。

まとめ

そういうわけで、再帰ジェネリクスを利用して親クラスでreturn (T) this;とやる場合、使い方によってはClassCastExceptionが発生する。Javaの型システムの限界があるわけだが、再帰ジェネリクスへのバインドに悪意がなければまぁ問題にはならない感じ。

再帰ジェネリクスなんてややこしい宣言の仕方をしなくてもサブクラス自身を表現する型変数の制約があればよいのになと思う。それであれば事例1や事例2みたいなのもコンパイルで弾いてほしいよね。