少し直観に反するJavaの言語仕様に気が付いたのでメモしておきます。
検証環境は Windows 版 OpenJDK 11 (build 28) です。
出来ないこと
public static <T> void hoge(@NonNull T value){
Class<? extends T> type = value.getClass(); // コンパイルできない
// type を使って作業したい
}
value はTかそのサブクラスのはずなので一見,問題なさそうな気がしますが,このコードはコンパイル出来ません.
GenericGetClass.java:3: エラー: 不適合な型: Class<CAP#1>をClass<? extends T>に変換できません:
Class<? extends T> type = value.getClass();
^
Tが型変数の場合:
メソッド <T>hoge(T)で宣言されているT extends Object
CAP#1が新しい型変数の場合:
? extends ObjectのキャプチャからのCAP#1 extends Object
エラー1個
何だかよくわからないエラーメッセージ…
何がダメなのか?
いくつか試してみます。
// 当然OK
public static void hoge(CharSequence value){
Class<? extends CharSequence> type = value.getClass();
}
// これもOK
public static <T extends CharSequence> void fuga(T value){
Class<? extends CharSequence> type = value.getClass();
}
// これはコンパイルできない
public static <T extends CharSequence> void piyo(T value){
Class<? extends T> type = value.getClass();
}
代入先の型に型変数 T を使っているとエラーになるようです。ここまで試して、 Object#getClass() の型の扱いが特殊だという話を思い出したので改めて調べました。
getClass() の仕様
API仕様書によると、
実際の結果型はClass extends |X|>です。ここで、|X|はgetClassが呼び出される式のstatic型の消去です。
そもそも、 Object クラスでは public final Class<?> getClass() のように定義されているので、
CharSequence cs = ...;
Class<?> type = cs.getClass()
は可能でも、普通のメソッドと同じように扱うと
Class<? extends CharSequence> type = cs.getClass()
のように代入することは出来ないことになります。そこで、言語使用で特別扱いしてこの場合getClass() の型が Class<? extends CharSequence> だとみなすわけですね。具体的には、型の制限がコンパイル時の変数の型(=「static型」)で決まります。
ここで問題になるのが、「static型の消去」になっていること。「消去」は翻訳として微妙な気もしますが、言語仕様§4.6のイレイジャ(erasure)のことでしょう。List<String> に対する List 、 class Hoge<T extends CharSequence> と宣言されているときの T に対する CharSequence 、などを示すようです。
今回の例では?
public static <T extends CharSequence> void fuga(T value){
Class<? extends CharSequence> type = value.getClass(); // OK
}
の場合は、value は T 型で、そのextends指定1 はCharSequenceなので、Tのイレイジャは CharSequence であり、getClass() の返り値の型は ? extends CharSequence となります。これは T やそのサブクラスとは限らないため、Class<? extends T> には代入できないんですね。
最初の例では、
public static <T> void hoge(@NonNull T value){
Class<? extends T> type = value.getClass(); // コンパイルできない
}
T のイレイジャは Object なので getClass() の型は ? extends Object になるわけです。
なぜこのような仕様になっているのか
ここからは筆者の推測がかなり含まれるのですが、まずそもそも総称型そのままではなくイレイジャが指定されている理由は、総称型だとこんなことが起こるからでしょう。
ArrayList<String> list = new ArrayList<>();
Class<? extends ArrayList<String>> type = list.getClass(); // 実際にはコンパイルエラー
いやな予感がしますね。
Classクラスのインスタンスは仕組み上詳細な型情報を持てない2ので、Class<ArrayList<String>> 型や Class<ArrayList<Integer>> 型のオブジェクトは、実際には同じClass<ArrayList>のインスタンスになります。Class<ArrayList>のメソッドはArrayList<T>の型変数Tが何に設定されているかを知ることができません。
つまり、Class<ArrayList<String>> という型の変数は危険です。もし上の例がコンパイルできると、
ArrayList<Integer> another = new ArrayList<>();
another.add(123);
ArrayList<String> casted = type.cast(another); // 実行時には単に ArrayList へのキャスト扱いなので通る
// ...
String str = casted.get(0);// ここで実行時エラー?
全体的にraw型を使わずに書いてコンパイルが通っているのに、予期せぬ場所で ClassCastException が起こることになりそうです。
総称型を使えない理由は分かりました。だからといって型変数の場合まで消さなくていいだろうと思ってしまったのですが、型変数Tが呼び出し側で List<String> に割り当てられたりしていれば結局同じです。だから冒頭のようなプログラムも許されないということなのでしょう。
余談
筆者がこの振る舞いに気が付いたのは、
@SuppressWarnings("unchecked")
public static <K extends Enum<K>> EnumMap<K,String> of(@NonNull K key, String value){
Class<K> type = (Class<K>) key.getClass(); // キャストしないとコンパイルエラー
EnumMap<K,String> map = new EnumMap<>(type); // 拡張されたEnum定数を渡すとここで実行時エラーになる
map.put(key, value);
return map;
}
のようなコードを書いていた時です3。この場合は、enumが final なので大丈夫な気がしていたのですが、よく考えれば、 enum の定数個別にメソッドとかを定義すると別クラスになるのでアウトでした。
あとがき
初めてQiitaの記事を書いてみましたが、考えて書いていると確かに知識の整理になりますね。至らぬ点があれば是非ご指摘ください。