2010年5月10日月曜日

ジェネリックスと可変長引数の微妙な関係

Javaのジェネリックス(総称)は、利用することはあってもAPIとして公開することはあまりなかったためこれまで気づかなかったが、可変長引数と組み合わせた場合に少々面倒な問題を持っていることに気づいたのでメモしておく。

JDK5でさまざまな新機能が追加されたが、ジェネリックスはその1つ。
一言で言ってしまえば、クラスやインタフェース、メソッドに型をパラメタとして定義できる機能である。
具体的な例とメリットはCollectionフレームワークでしばしば説明されている。

java.util.List<E>の例を以下に示す。
JDK5以前のコードは以下のような感じになる。


public void processMyClasses(List list) {
    for (Iterator iter = list.iterator(); iter.hasNext(); ) {
        MyClass myClass = (MyClass) iter.next();
        // myClass に対して何か処理
    }
}

for文の中で、MyClassへの明示的なキャストを行っている。
list引数の要素がすべてMyClassにキャストできるオブジェクトであることを期待して実装しているが、もし誤った要素が入っていれば、キャスト時にClassCastExceptionが発生してしまう。
このバグはコンパイル時には発覚しない。実行して初めて(最悪の場合バグにヒットする処理が行われるまでかなりの時間が経ってから)問題があることが判明する。
例外はここで発生するが、バグを作り込んだ箇所(おかしな要素をaddしたところ)は別の場所であり、デバッグがやっかいになる。
もちろん例外が発生しないようにinstanceof演算子で型チェックすることができるが、冗長であり、可読性を下げることになる。またデバッグが困難であることに変わりはない。

JDK5以降は、同じ処理を以下のように記述できる。

public void processMyClasses(List<MyClass> list) {
    for (Iterator<MyClass> iter = list.iterator(); iter.hasNext(); ) {
        MyClass myClass = iter.next();
        // myClass に対して何か処理
    }
}

for文の中でMyClassへの明示的なキャストが必要なくなっているのがミソだ。型パラメタを指定することで、その型以外の要素がリストに含まれないことがコンパイル時に保証される。

さらに、型パラメタを指定することで、同じくJDK5で導入された拡張for文を利用することができるようになる。

public void processMyClasses(List<MyClass> list) {
    for (MyClass myClass : list) {
        // myClass に対して何か処理
    }
}

型パラメタを指定しないList(原型)を使用すると、拡張for文の左側の型はMyClassではなくObjectでしか宣言できなくなる。
後で明示的にキャストしなければMyClassのメソッドを使用することができない。
バグ等による型保証の問題についても、通常のfor文と同様である。

このようなList<E>の特徴は、型Eの配列(E[])と非常に似ている。しかし、ジェネリックスと配列では、2つの点で大きく異なる。

1つはジェネリックスはerasureで実装されているという点である。つまりジェネリックスによる型チェックはコンパイル時に行われ、その後抹消される。コンパイルされた後のバイトコードにはその情報は残らない。これはバイトコードをデコンパイルするとはっきりするだろう。上記の例でいうと、デコンパイルされたコードのListはすべて原型が用いられ、明示的なキャストが自動的に追加される。つまり、JDK5以前のコードとほぼ同様のバイトコードができあがる。

もう1つの大きな違いは、配列は共変(covariant)であるのに対し、ジェネリックスは不変(invariant)であるということだ。共変とは、クラスBがクラスAのサブクラスであった場合、クラスBの配列はクラスAの配列に暗黙的にキャストできることを意味する。

class A {...}
class B extends A {...}
であるとき:

B[] b = new B[10];
A[] a = b;

上記のコードはエラー無くコンパイルできる。これに対しジェネリックスは不変であるため、同様な記述が許されない。

List<B> b = new ArrayList<B>();
List<A> a = b;

上記のコードはコンパイルできない。
共変は一見直感的であり、便利なような気がするが、Javaの配列は参照であるが故にやっかいな問題を引き起こす。
例えば以下のコードはコンパイル時にはエラーが発生しない。

class A {...}
class B extends A {...}
class C extends A {...}
であるとき:

B[] b = new B[10];
A[] a = b;
a[0] = new C();
B tmp = b[0]

しかし、上記コードを実行すると、配列bの1番目の要素を取り出そうとしたときに例外が発生する。これはやっかいな問題である。上記コードであれば、例外の発生する箇所からさかのぼって、配列bにおかしな値を代入した犯人を突き止めればよいが、これが別メソッドの引数であったりすると追いかけるのが困難になってくる。
何よりバグがコンパイル時に自動的に見つからず、実行時に初めて発覚する可能性があるという点がやっかいだ。
もしジェネリックスが共変を許容すると、この配列の問題と全く同じことがジェネリックスでも起こる。せっかくの型保証が不完全になってしまうのだ。

以上のような大きな違いがあるため、配列とジェネリックスは非常に相性が悪い。原則としてコードの中に混ぜない方がよい。Joshua Bloch著 "Effective Java 第2版" の項目25では、"配列よりリストを選ぶ"ことが推奨されている。
共変が引き起こす上記のような問題を避けるため、例えば、型パラメタを指定した型の配列をnewすることはできない。"new ArrayList<String>[10]" はコンパイルエラーになる。

さて、いよいよ本題に入るが、ジェネリックスや拡張for文同様、JDK5で新たに導入された機能として、可変長引数がある。これはC言語にあるprintf文の様な書式指定の出力を実現するために導入されたといっても過言ではないが、その他の場面でも非常に便利な機能である。
ところが、これをジェネリックスと組み合わせた場合にちょっとやっかいなことが起こる。

可変長引数とジェネリックスを組み合わせたサンプルとして、複数のリストを引数として内容をマージして返すジェネリックメソッドを考える。まずは2つのリストの例。

static <E> List<E> concat(List<E> list1, List<E> list2) {
    List<E> result = new ArrayList<E>();
    result.addAll(param1);
    result.addAll(param2);
    return result;
}

これはなんの問題も無い。これを2つ以上のリストを引数とする可変長引数に修正したのが以下になる。

static <E> List<E> concat(List<E> list1, List<E> list2, List<E>... lists) {
    List<E> result = new ArrayList<E>();
    result.addAll(list1);
    result.addAll(list2);
    for (List<E> list : lists)
        result.addAll(list);
    return result;
}

最低2つのリストを要求するため、可変長引数は3番目に追加されている。これ自体はなんの警告もなくコンパイルできる。
しかし、このメソッドを利用しようとすると、コンパイル時に警告が出る。

List<String> list1 = Arrays.asList("a", "b", "c");
List<String> list2 = Arrays.asList("d", "e", "f");
List<String> list3 = Arrays.asList("g", "h", "i");
List<String> result = concat(list1, list2, list3);

上記コードをコンパイルすると、以下の警告が出力される。

注:Xxx.java の操作は、未チェックまたは安全ではありません。
注:詳細については、-Xlint:unchecked オプションを指定して再コンパイルしてください。

注記の通り、-Xlint:uncheckedオプションを指定してコンパイルし直すと、より詳細な警告が出力される。

Xxx.java:xx: 警告:[unchecked] 可変引数パラメータに対する型 java.util.List<java.
lang.String>[] の総称型配列の無検査作成です。
    List<String> result = concat(list1, list2, list3);
                                               ^
警告 1 個

コード上はきちんと型指定されているように見えるのに、総称型配列の無検査作成、つまり原型を使用していると警告が出る。これはなぜだろうか。
答えはコードをデコンパイルしてみると分かる。上記のconcatメソッド呼び出し箇所は、以下のようにコンパイルされている。

concat(list, list1, new List[] { list3 });

可変長引数は、そのすべての引数を含む配列に変換され、呼び出しを行っている。
ここで思い出してもらいたいのだが、共変の問題のため、型パラメタを指定した型の配列をnewすることはできない。従って上記の引数を、"new List<String>[] { list3 }"というように記述することはできない。デコンパイルしたコードはすべての型パラメタが抹消されているが、上記の可変長引数部分は始めから型パラメタが指定されていない(原型)ということになる。このため、コンパイラが型安全でないことを警告しているのだ。
結局可変長引数が配列で実装されているため、ジェネリックスとの相性の悪さが露呈することになった。

ジェネリックスを使用してAPIを実装する場合、どうしても型安全でない警告が発生してしまうことがある。
しかしそれはたいていの場合内部の実装において発生する警告であり、APIを利用する側からは見えない。
上記の問題はAPIを利用する側で警告が発生してしまう。
対策としてはAPI自体を別の形に修正するか、利用者に逐一無検査警告を取り除いて貰うしかない。
無検査警告を取り除くには、以下のようにSuppressWarningsアノテーションを指定する。

@SuppressWarnings("unchecked")
List<String> result = concat(param1, param2, param3);

SuppressWarningsアノテーションは、ローカル変数の他にもメンバ変数やメソッド、クラスなどに指定することができる。
しかし、このアノテーションを指定することは、いわばクサいものに蓋をすることであり、必要以上に指定してしまうと本当の問題があった場合にそれを見えなくしてしまう。指定は極力ローカルにとどめるべきだ。
上記のようにローカル変数に対して個別に指定するのが望ましい。また、なぜ警告を抑制して良いのかについて、コメントを入れておくことがEffective Javaでは推奨されている。

これは非常にカッコ悪いが、ちょっとどうしようもない問題のようだ。
もし私がなにか勘違いしているだけなのか、あるい根本的な対策があるならばぜひ教えていただきたい。