比較的厳格な型付け言語として有名なJavaにおいて、実行時に型の取り扱いが曖昧になりシステムエラーが発生する事象がありました。
WEB上にそれほどナレッジがなく、事象としても興味深かったので、整理してみました。
なお、当記事の前提は以下となります。
- JavaのGenericsやオーバーロードなど、基本的な言語仕様の解説はいたしません。
- 当記事に掲載している実験用ソースコード(コードブロックにファイル名を明記しているコード)は、全てそのブロック単体でコンパイルと実行ができるようになっているので、興味のある方はぜひご自身のローカルで試してみてください。
環境
Oracle jdk1.8系
% java -version
java version "1.8.0_281"
Java(TM) SE Runtime Environment (build 1.8.0_281-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.281-b09, mixed mode)
事象
下記の様な実装にて、ClassCastExceptionが発生しました。
public class GenericOverload {
public static void main(String[] args) {
try {
// Genericsを使用して、値0の型をIntegerに決定し、そのInteger型の値をString.valueOfで文字列化してコンソールに出力する。
System.out.println(String.valueOf(GenericOverload.cast(0, Integer.class)));
} catch(Exception e) {
e.printStackTrace();
}
}
public static <T> T cast(Object obj, Class clazz) {
return (T) clazz.cast(obj);
}
}
上記のコードはJavaの実装としてはなんだか回りくどいことをしていますが、構文上は間違っているわけではありません。
上記のコードをjdk1.8で実行すると下記のようなExceptionが発生します。
java.lang.ClassCastException: java.lang.Integer cannot be cast to [C
[C
ってなんすか?w
まず[C
がなんなのかWebで情報を調べたところ、どうもchar[]
(プリミティブキャラクターの配列)のことらしいというところまでは分かったので、下記の実証実験コードで再現してみました。
public class CharArrayCastException {
public static void main(String[] args) {
try {
// 数値をchar[]にキャストしようとして実行時にClassCastExceptionを出したい。
// 実装コード上で数値をそのままchar[]にキャストするとコンパイルエラーになるため、数値を一度Objectにキャストしてから、char[]にダウンキャストする。
char[] result = (char[]) (Object)1;
}catch(ClassCastException cce) {
cce.printStackTrace();
}
}
}
実行した結果、期待通りのExceptionが発生したので、[C
はchar[]
のことで確定で良いと思います。
java.lang.ClassCastException: java.lang.Integer cannot be cast to [C
at CharArrayCastException.main(CharArrayCastException.java:8)
で。
今回の事象はchar[]
に型変換できないオブジェクトをchar[]
に型変換しようとしていることは分かりました。
ただし、もともと意図しているコードはInteger
をString
に変換したいだけなのだが、なぜchar[]
に一度変換しようとしているのかが問題です。
解決への手がかり① ちょっとだけJavaの言語仕様のおさらい
Javaにもオーバーロードという仕組みがあって、String.valueOfというメソッドも複数のオーバーロードメソッドが存在します。
JavaのAPIドキュメントによると、9個存在するようです。
この中から、今回の事象においては、
String.valueOf(int i)
またはString.valueOf(Object obj)
が呼ばれていれば実装意図通りの処理が行われたものに対して、なぜかString.valueOf(char[] data)
が呼ばれたことになります。
解決への手がかり② 型が曖昧になる問題
問題の実装において、型パラメーターを指定してそのパラメーターの型でキャストされた値は、コンパイル(静的解析時)には型は不明なまま処理され、ランタイム(実行時)において型が確定されるものの、呼び出し元で戻り値を受け取らずに次のメソッドに戻り値を渡すと型が確定されないまま次の呼び出し先のメソッドに引数として渡されるようです。
解決への手がかり③ではなぜ、char[]
と誤認した?
実際にJDK内に同梱されているStringクラスのソースを見てみました。
(巨大なクラスであるため、要点を抜粋)
/**
* Returns the string representation of the {@code Object} argument.
*
* @param obj an {@code Object}.
* @return if the argument is {@code null}, then a string equal to
* {@code "null"}; otherwise, the value of
* {@code obj.toString()} is returned.
* @see java.lang.Object#toString()
*/
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
/**
* Returns the string representation of the {@code char} array
* argument. The contents of the character array are copied; subsequent
* modification of the character array does not affect the returned
* string.
*
* @param data the character array.
* @return a {@code String} that contains the characters of the
* character array.
*/
public static String valueOf(char data[]) {
return new String(data);
}
/**
* Returns the string representation of a specific subarray of the
* {@code char} array argument.
* <p>
* The {@code offset} argument is the index of the first
* character of the subarray. The {@code count} argument
* specifies the length of the subarray. The contents of the subarray
* are copied; subsequent modification of the character array does not
* affect the returned string.
*
* @param data the character array.
* @param offset initial offset of the subarray.
* @param count length of the subarray.
* @return a {@code String} that contains the characters of the
* specified subarray of the character array.
* @exception IndexOutOfBoundsException if {@code offset} is
* negative, or {@code count} is negative, or
* {@code offset+count} is larger than
* {@code data.length}.
*/
public static String valueOf(char data[], int offset, int count) {
return new String(data, offset, count);
}
(中略)※このあたりにcopyValueOfメソッドが実装されているが、今回の論点ではないので割愛
/**
* Returns the string representation of the {@code boolean} argument.
*
* @param b a {@code boolean}.
* @return if the argument is {@code true}, a string equal to
* {@code "true"} is returned; otherwise, a string equal to
* {@code "false"} is returned.
*/
public static String valueOf(boolean b) {
return b ? "true" : "false";
}
/**
* Returns the string representation of the {@code char}
* argument.
*
* @param c a {@code char}.
* @return a string of length {@code 1} containing
* as its single character the argument {@code c}.
*/
public static String valueOf(char c) {
char data[] = {c};
return new String(data, true);
}
/**
* Returns the string representation of the {@code int} argument.
* <p>
* The representation is exactly the one returned by the
* {@code Integer.toString} method of one argument.
*
* @param i an {@code int}.
* @return a string representation of the {@code int} argument.
* @see java.lang.Integer#toString(int, int)
*/
public static String valueOf(int i) {
return Integer.toString(i);
}
/**
* Returns the string representation of the {@code long} argument.
* <p>
* The representation is exactly the one returned by the
* {@code Long.toString} method of one argument.
*
* @param l a {@code long}.
* @return a string representation of the {@code long} argument.
* @see java.lang.Long#toString(long)
*/
public static String valueOf(long l) {
return Long.toString(l);
}
/**
* Returns the string representation of the {@code float} argument.
* <p>
* The representation is exactly the one returned by the
* {@code Float.toString} method of one argument.
*
* @param f a {@code float}.
* @return a string representation of the {@code float} argument.
* @see java.lang.Float#toString(float)
*/
public static String valueOf(float f) {
return Float.toString(f);
}
/**
* Returns the string representation of the {@code double} argument.
* <p>
* The representation is exactly the one returned by the
* {@code Double.toString} method of one argument.
*
* @param d a {@code double}.
* @return a string representation of the {@code double} argument.
* @see java.lang.Double#toString(double)
*/
public static String valueOf(double d) {
return Double.toString(d);
}
実際のソースを見てみると、実装順序がJavaのAPIドキュメントに提示されている順序とは違い、下記の順序であることが分かります。
- オブジェクト型
- キャラクター型の配列
- キャラクター型の配列(オフセット付き)
- プリミティヴ型
実装順序で判断するならば、オブジェクト型を引数に取るString.valueOf(Object obj)
が呼ばれるのが自然だが、その中の実装がobj.toString()
であり、Java言語のオートボクシング実装前の歴史的背景も考慮すると、プリミティヴ型が渡されたときに、静的解析レベルでのエラーが発生するため、不明な型が渡されたときは、プリミティブ系の最初の実装であるString.valueOf(Char[])
をとりあえず呼んでおくという、JavaAPIの実装意図であると推測できます。
AutoBoxing実装前(jdk1.4以前)では、String.valueOf(Object obj)
に万が一プリミティヴな値が渡されてきた時に、1.toString()
のような実行できない処理が行われることになりますね。
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
補足:
jdk1.5以降はAutoBoxingが実装され、実際は引数で渡されてきた値がプリミティヴ型であれば、toString()が呼ばれる前に、対応するラッパークラスで包まれて、上記の実行そのものは可能です。
しかしAutoBoxingが実装される以前から、当然StringクラスとvalueOfメソッドは存在しているので、上記のような考察に至っています。
(参考:jdk1.4 APIドキュメント)
結論
下記の複合要因で、JavaのGenericsを使用してキャストした値に対して、明示的に型を指定しないでオーバーロードされたメソッドに値を引き渡すと、意図しないメソッドが呼ばれてしまう可能性があります。
- Genericsを使用すると動的型付けとなり、ソースを汎用的に書くことが可能であるが、実行時にリアルタイムに型が決定されるとは限らない。
- 型が不明な状態でJVMがオーバーロードメソッドから呼び出すべきメソッドを特定する時がある。その場合のメソッド選定基準が意外といい加減だと思われる。
(型が不明なままオーバーロードメソッドを呼び違えるのはJava言語の不備なのではないかと思わされる)
対策・心がけたいこと
メソッドからの戻り値をそのまま次のメソッドの引数に指定するのはコードが短くかけて便利だけど、Javaにおいては本来型宣言が厳格に行うことが特徴の言語であるため、一つ一つ丁寧に書いた方がいいのかもしれない。
public class GenericOverload {
public static void main(String[] args) {
try {
// 一行でスマートに描きたいが。
System.out.println(String.valueOf(GenericOverload.cast(0, Integer.class)));
//Step by Stepでちゃんと型を明確に、ローカル変数で受け取りながら処理するのが安全
Integer castedValue = GenericOverload.cast(0, Integer.class); //ここで型が明示的に指定される
String result = String.valueOf(castedValue);
System.out.println(result);
} catch(Exception e) {
e.printStackTrace();
}
}
public static <T> T cast(Object obj, Class clazz) {
return (T) clazz.cast(obj);
}
}
というか・・・
型に厳格なJava言語という先入観を捨てたほうが良さそうですね。型は単に静的解析における品質担保の一つのツールぐらいの認識がちょうどよいのかも知れません。