Hatena::ブログ(Diary)

達人プログラマーを目指して このページをアンテナに追加 RSSフィード Twitter

2011-03-21

普通の(業務)Javaアプリケーションでは配列をなるべく使用しない方がよい

以前、業務系のJavaプログラマーが知っておくべき10個のBad Partsとその対策 - 達人プログラマーを目指してにて、Java言語の配列はListなど他のコレクションとの不統一が顕著であるという点を説明しました。Java言語の配列

  • 要素に[ ]演算子を使って簡単にアクセスできる
  • 構文がC言語C++言語に近いため親しみやすい
  • 型情報を持っているため、要素の取得時にキャストが不要
  • int[ ]、byte[ ]など大量の基本型データを効率的に処理できる
  • パラメーターに対して型の共変性*1があり直感的に理解しやすい

などの特徴があります。特にJDK1.4以前は総称型(Generics)という仕組みが存在せず、配列は要素にキャストなしでアクセスできる唯一のコレクションでした。そのような理由もあり、Java5以降を利用しているプロジェクトでも、ListなどのコレクションAPIのクラスを利用せずに配列を過剰に使用する傾向があるのではないかと思われます。*2

ただし、配列は総称型とは水と油のような関係ということがあり、また、Javaの型安全性を損なう原因になっているという事実があります。*3ここでは、Java配列の問題点についてあらためて考えてみたいと思います。

配列を使う以上Javaプログラムは型安全でない

Java言語では、配列の型と要素型との間に共変性が存在します。したがって、String[ ]やNumber[ ]はObject[ ]のサブクラスとなります。この性質は直感的には非常にわかりやすいのですが、以下の例を考えてみるとわかるように、既にこの時点でコンパイル時の型安全性が損なわれています。

String[] strArray = {"test1", "test2"};
Object[] objArray = strArray;

objArray[0] = 3; //java.lang.ArrayStoreException 

以上のコードはコンパイル時にはエラーにも警告にもならず、実行時例外となります。ClassCastExceptionではないため、型安全性とは別であるというつっこみもあるかもしれませんが、配列を使うと型に関して以上のような例外が実行時に出る可能性があることを覚えておいてもよいと思います。

Javaの総称型は型消去(type erasure)によって実現されている

JDK1.4までのJavaではGenericsが存在しなかったため、ソースコード中で宣言された変数の型はコンパイルされても基本的にそのままバイトコード中に型情報が残ると考えることができます。実際、Javaのバイトコードの仕様を見てみるとわかるのですが、すべての基本型ごとに別々の命令セットがあり、参照型についても対象とするクラスに関する情報が渡されるようになっています。プロフェッショナルJavaプログラマーにも意外に知られていない事実なのかもしれませんが、バイトコードコンパイルした状態でも相当型安全性を意識しているということですね。そして、配列の型の情報も次元も含めて厳密にバイトコード中に保存されるしくみになっています。(型情報や名前を含めてほぼ完全にソースに逆コンパイル可能)前節の例で実行時例外になるのは、String[ ]とObject[ ]との間でVMがきちんと型情報を区別してチェックしている証拠です。

この時代までは、このようにJavaの型システムは非常に単純でした理解しやすいものでした。しかし、Java5になって総称型の仕組みが導入されることになり、正しく理解する上で注意が必要になっています。Java5では総称型のクラスを定義することができるように拡張されており、ArrayList<E>やClass<T>に代表されるようにJDKの多くのクラスも総称型に拡張されています。Java5で総称型が導入されたメリットは明らかであり、Listなどの要素に実行時のキャストなしでアクセス可能であり、コンパイル時のエラーと警告を無視しない限りにおいて(総称型の使用に関する部分では)完全に型安全であることが保障されます。

直感的には配列と同様ArrayList<String>などのパラメータ化された型についても、バイトコード中に型情報が保持されるべきなのではないかと思われるのですが、そのようにしてしまうとJDK1.4以前のライブラリーと互換性を維持することが難しくなってしまいます。そこで、Java5では型消去(type erasure)という方式が採用されています。型消去により、ArrayList<String>もArrayList<Integer>もバイトコード上は区別がなく、共にArrayListに変換されてしまいます。*4

型消去について理解を深めるには、実際にコンパイル後のバイトコードを調査してみるのが一番です。たとえば、以下のソースコードを考えます。

List<String> list = new ArrayList<String>();
list.add("test1");
list.add("test2");

String item1 = list.get(0);
String item2 = list.get(1);

これをコンパイルすると、実際にバイトコード上では以下と等価なコードに変換されていることがわかります。*5

List list = new ArrayList();
list.add("test1");
list.add("test2");

String item1 = (String)list.get(0);
String item2 = (String)list.get(1);

これはJDK1.4のコードそのものですね。*6もともとのソースのArrayList<String>はArrayListになっていますし、要素アクセス時にはString型に対するキャストが埋め込まれています。*7

型消去では具象化可能型(reifiable type)という考えを理解することがポイント

このように一般にはコンパイル時に型消去が行われるのですが、型消去の影響を受けずに最終的にバイトコード中で型情報が失われない型は具象化可能型(reifiable type)と呼ばれています。具象化可能型には以下のようなものがあります。

逆に、それ以外の型はすべて具象化可能型ではありません。

型消去を正しく理解するにはどの型が具象化可能型であるのかを正しく知っておくことがポイントです。

配列の要素型は具象化可能型でなくてはnewできない

前置きが長くなりましたが、これから配列と総称型の相性が悪いという点について見ていきます。まず、Java言語では任意の要素型の配列変数を定義できるという事実があります。当然と思われるかもしれませが、以下のように任意の要素型の配列変数の宣言をすることが可能です。以下の宣言はすべて問題なくコンパイルが通ります。

int[] a1;
String[] a2;
T[] a3;
List<String>[] a4;
List<? extends Number>[] a5;

しかし、配列の生成に関しては要素型が具象化可能型でなくてはnewできないという重大な制約があります。実際に試してみると以下のように下の3つの文はコンパイルエラーとなります。

int[] a1 = new int[10]; // OK
String[] a2 = new String[3]; // OK
T[] a3 = new T[3]; // Tの総称配列を作成できません。
List<String>[] a4 = new List<String>[3]; // List<String>の総称配列を作成できません。
List<? extends Number>[] a5 = new List<? extends Number>[3]; // List<? extends Number>の総称配列を作成できません。

配列が持つこの制約は存在理由が分かりにくいのですが、バイトコード中で配列の生成時に配列要素の型が必要になるという点を思い出せば納得ができます。よって、配列要素の型が具象化可能型でない場合、実際の型が残らないため配列を生成するバイトコードに直接変換できないのです。具象化可能でない場合、型消去された要素型の配列を代わりに生成するという仕様でもよかったかもしれませんが、ソースコード中の意図に反して別の要素型の配列が生成されるというのは混乱を招くという判断なのだと思われます。

どうしても、コンパイルを通すためには、以下のようにして具象化可能型の要素を持つ配列を生成してから、キャストするしかありません。

int[] a1 = new int[10]; // OK
String[] a2 = new String[3]; // OK
T[] a3 = (T[])new Object[3]; // 警告、Object[]からT[]への未検査キャスト
List<String>[] a4 = (List<String>[])new List<?>[3]; // 警告、List<?>[]からList<String>[]への未検査キャスト
List<? extends Number>[] a5 = (List<? extends Number>[])new List<?>[3]; // 警告、List<?>[]からList<? extends Number>[]への未検査キャスト

ただし、このキャストは型安全ではなく今度は警告となります。このキャストが実際にどうして型安全でないかの理由を考えるのは意外と難しいですが、たとえば以下の例で、実際に実行時例外となります。

public static void main(String[] args) {
	String[] result = hello("hello1", "hello2"); // ClassCastException ---(A)
}

public static <T> T[] hello(T t1, T t2) {
	T[] result = (T[])new Object[]{t1, t2}; // 警告、Object[]からT[]への未検査キャスト ---(B)
	return result;
}

ただし、ClassCastExceptionは一見想定外の行で発生していることに注意してください。明示的にキャストを行っている(B)の行では例外は発生せず、(A)の行でClassCastExceptionが発生します。どうしてこのようになるかというと、実際バイトコードコンパイルした結果を逆コンパイルするとわかるのですが、型消去により、上記のコードは以下と等価なコードに変換されるからです。

public static void main(String[] args) {
	String[] result = (String[])hello("hello1", "hello2");// ClassCastException ---(A')
}

public static Object[] hello(Object t1, Object t2) {
	Object[] result = new Object[]{t1, t2}; // ---(B')
	return result;
}

(A')の行でObject[ ]のインスタンスをString[ ]にキャストすることは不正ですから例外となるのです。このように任意の要素型の配列を宣言できるのに、要素が具象化可能な型の配列しかnewできないというちょっと矛盾した仕様であるため、要素が具象化可能でない配列を使うにはどこかで警告が出ること(つまり型安全でないということ)を避けられないというのが事実なのです。

このような制約は配列に固有のものです。たとえば、配列をListに置き換えると、すべてのケースでまったく問題なくコンパイル可能ですし、型安全性も保障されます。*8

List<Integer> list1 = new ArrayList<Integer>(); // OK。ただしList<int>は不可。
List<String> list2 = new ArrayList<String>(); // OK
List<T> list3 = new ArrayList<T>(); // OK
List<List<String>> list4 =  new ArrayList<List<String>>(); // OK
List<List<? extends Number>> list5 = new ArrayList<List<? extends Number>>(); //OK

メソッドの可変長パラメーターの型に関する制約

実は、配列に関しては明確にnewしない場合でも暗黙的に生成される場合があります。Java5では可変長パラメーターの仕組みが導入されたのですが、可変長パラメーターは糖衣構文に過ぎず、バイトコード上は配列の生成に変換されることになります。したがって、以下のように具象化可能型でない型の可変長パラメーターで宣言されたメソッドを呼び出す側で警告となります。結果として具象化可能型でない要素型を持つ配列の生成が必要になるからです。

public void test(T test) {
	test1("test", "test2");
	test2(test, test); // 警告、List<T>の総称配列は可変引数パラメーターに対して生成されます。
	test3(Arrays.asList("test"), new ArrayList<String>()); //  警告、List<String>の総称配列は可変引数パラメーターに対して生成されます。
}

public void test1(String... args) {
...
}

public void test2(T... args) {
...
}

public void test3(List<String>... args) {
...
}

ただし、この場合はnewと違って、エラーではなく警告になります。たとえば、上記のtest3の場合、本来は暗黙的にList<String>[ ]のインスタンスを生成する必要があります。しかし、それは実際には不可能なため、List[ ]を代わりに生成するコードが生成されます。前節の例でキャストすることにより警告となった場合と似ていますが、今度はコンパイラーが配列を勝手にnewする仕様のため、エラーではなく警告となるようです。これも、生成されたバイトコードを実際に逆コンパイルしてみると明らかになりますね。*9

public void test(Object test) {
	test1(new String[] { "test", "test2" });
	test2(new Object[] { test, test });
	test3(new List[] { Arrays.asList(new String[] { "test" }), new ArrayList() });
}

public void test1(String[] args) {
...
}

public void test2(Object[] args) {
...
}

public void test3(List[] args) {
...
}

ただし、これも理論上型安全でないケースがあるということであり、実際に問題となるケースを探すのは容易なことではありません。*10たとえば、実際上はありえないコードですが、以下のコードでは警告もエラーも出ませんがClassCastExceptionを発生させることができます。

public void test3(List<String>... args) {
	List<?>[] list = args;
	List<Integer> list0 = new ArrayList<Integer>();
	list0.add(1);
	list0.add(2);
	list[0] = list0;
		
	String item = args[0].get(0); // ClassCastException
}

結局何が問題かというと、本来newが禁止されているList<String>[ ]のインスタンスが裏で生成されてしまっているということですね。この問題を検出するために可変引数メソッドを呼び出す側で警告になるのです。

配列を使ったAPIにおける「景品表示法の原理」と「公然わいせつ罪の原理」

以前にもJava言語で固定要素のListを初期化する際のイディオム - 達人プログラマーを目指してで紹介したJava Generics And Collectionsでは、第6章で配列を使って型安全なAPIを正しく設計する上で心がけるべき二つの原理が紹介されています。結構微妙な問題を扱っているため、理解するのは簡単ではないのですが、覚えやすいようにそれぞれに印象的でユニークな名前が付いています。

景品表示法の原理(Principals of truth in advertising)

この原理は、以下のように定義されています。

The reified type of an array must be a subtype of the erasure of its static type.

配列の具象化型はその静的な型に対する消去型のサブタイプでなくてはならない。)

この一文を読んでもわかりにくいのですが、例としては以下のようなケースがこの原理に違反するケースとなります。

import java.util.Arrays;
import java.util.Collection;
import java.util.List;


public class TruthInAdvertising {
	public static void main(String[] args) {
		List<String> sampleList = Arrays.asList("test1", "test2");
		
		String[] sampleArray = toArray(sampleList); // ClassCastException
	}
	
	public static <T> T[] toArray(Collection<T> c) {
		@SuppressWarnings("unchecked") // 不適切な警告の無視
		T[] result = (T[])new Object[c.size()]; 
		int i = 0;
		for (T t : c) {
			result[i++] = t; 
		}
		return result;
	}
}

以上の例では、Collectionから配列に変換する共通ルーチンの中で不適切に警告が無視されています。その結果、見かけ上型安全のように見せかけていながら、実際にはClassCastExceptionが警告なしで発生するようになっています。この場合、配列の具象化型はObject[ ]ですが、sampleArrayの型消去後の静的型はString[ ]でTもStringにバインドされますから、この原理に違反しています。この原理が無視されると本当はObject[ ]でしかない配列が、コンパイラーに対しては見かけ上T[ ]つまりString[ ]のように見えてしまうことになるのですが、その点が誇大広告のようで景品表示法違反だということが言いたいのでしょう。

公然わいせつ罪の原理(Principals of indecent exposure)

もう一つの公然わいせつ罪の原理は以下のように定義されます。

Never publicly expose an array where the components do not have a reifiable type.

配列の要素型が具象化型可能型でない限り、配列を公に晒してはならない。)

これについては、原理に違反しているかどうかは簡単に見分けがつきますが、以下のような例が考えられます。

import java.util.Arrays;
import java.util.List;


public class IndecentExposure {
	public static void main(String[] args) {
		List<Integer>[] intLists = intLists(1);
		
		List<? extends Number>[] numLists = intLists;
		numLists[0] = Arrays.asList(1.01);
		
		int i = intLists[0].get(0); // ClassCastException
	}
	
	public static List<Integer>[] intLists(int size) {
		@SuppressWarnings("unchecked") // 不適切な警告の無視
		List<Integer>[] intLists = (List<Integer>[])new List[size]; 
		for (int i = 0; i < size; i++) {
			intLists[i] = Arrays.asList(i + 1); 
		}
		
		return intLists;
	}
}

すでに説明したように、型安全性のために要素型が具象化型可能型でない配列のnewは禁止されているのですから、こうした配列がpublicなAPIから警告なしに取得できるということは、どこかで警告が握りつぶされているということに他なりません。

ちなみに、JDKjava.lang.Class#getTypeParameters()の戻り値の型はTypeVariable<Class<T>>[ ]で公然わいせつ罪の原理に違反しています。本来であれば、配列でなくて変更不能なListを返すべきだったのでしょう。(一般的には既に説明したようにTなどの総称型変数は具象化可能型ではありませんが、Class<T>を型トークンとしてパラメーターにとり、T[ ]を返すメソッドは標準APIにも多数みられます。この場合、TがStringなど具象化可能型に正しくバインドされることがClassクラスの構造上実質的に保障されるため、この原理に違反しないという理解なのかと思います。)

総称型配列を扱うにはどうすればよいのか

くさいものには蓋をするという考えで

このように配列と総称型は言語仕様上水と油の関係であり、警告なしで(型安全性を犠牲にせずに)共存できません。このことに対する対処としてはまず、総称型の配列を使っていることを特定のクラスの内部にカプセル化してしまうという考え方があります。そのクラスの中で型安全であるということをロジック上保障した上でならば、堂々とコンパイラーの警告を無視することができます。そのようにがんばって総称型配列の仕様を隠蔽している例は、おなじみのJava5のArrayListクラスの実装に見ることができます。この実装では総称配列E[ ]をフィールドで宣言しているのですが、本エントリで説明した事情でE[ ]は生成できないため代わりにObject[ ]を生成してキャストするようになっています。コンパイラーによる型安全性のなさはロジックで保障しているということです。

(Java5のArrayListより抜粋)

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{

    private transient E[] elementData;

    private int size;

    public ArrayList(int initialCapacity) {
	super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
	this.elementData = (E[])new Object[initialCapacity];
    }

...


    public E get(int index) {
	RangeCheck(index);

	return elementData[index];
    }

    public E set(int index, E element) {
	RangeCheck(index);

	E oldValue = elementData[index];
	elementData[index] = element;
	return oldValue;
    }

}

ただし、ちょっと興味深いことですがJava6では以下のように総称配列を利用しない形式にリファクタリングされていました。今度は総称配列を利用する代わりに直接Object[ ]をフィールドで宣言し、型を返す際に明示的に型キャストするような実装に変更されています。

(Java6のArrayListより抜粋)

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{

    private transient Object[] elementData;


    public ArrayList(int initialCapacity) {
	super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
	this.elementData = new Object[initialCapacity];
    }

...

    public E get(int index) {
	RangeCheck(index);

	return (E) elementData[index];
    }

    public E set(int index, E element) {
	RangeCheck(index);

	E oldValue = (E) elementData[index];
	elementData[index] = element;
	return oldValue;
    }
}

結局、newが禁止されている要素型が具象化可能でない配列型を、宣言できるということ自体がよくない仕様だったのではないかということが、最近言われるようになってきているところがあるのかもしれません。総称型の配列宣言を使わず原始的なキャストで済ますというのが最近のトレンドということでしょうか?

総称型配列のnewを避けるためにリフレクションを利用する

総称型配列はnewすることができませんが、すでに既存の配列インスタンスが存在していれば、その実行時の型情報を利用して配列インスタンスを生成することができます。一種のプロトタイプパターンのような感じですが、このテクニックはJava5のArrayListのtoArray()メソッドで利用されています。

    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            a = (T[])java.lang.reflect.Array.
		newInstance(a.getClass().getComponentType(), size);
	System.arraycopy(elementData, 0, a, 0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }

総称配列を生成する別の手段としては、Java6のArrays.copyOf()で利用されているようにClassクラスのインスタンスを渡すという方法も考えられます。

    public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
        T[] copy = ((Object)newType == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(newType.getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

まとめ

以上、総称型と配列の問題点についてまとめます。

  • 配列パラメーター型の共変性と具象化という点で特異な型である。
  • Java5以降では総称型やパラメーター化された型を要素にもつ配列変数を宣言できる。
  • しかし、要素型が具象化可能型でない限りnewすることができないという矛盾がある。
  • キャストにより無理やりこうした配列インスタンスを生成することはできるが型安全性が損なわれる。

このようにJava配列は型消去に基づくJavaの総称型の実装と非常に相性が悪いという事実があります。画像処理やバイトデータの処理などのように低水準のデータ処理が必要なケースやArrayListの実装などのようにどうしても必要なケースを除くと、なるべく配列を使わないようにするというのも一つの解決策なのかもしれません。特に、Java EE上の業務アプリケーションではJPA配列の相性が悪いということもありますし、なるべく配列の使用を控えるのが良いのではないかと思います。

ただし、そうはいっても配列の[ ]演算子による簡単な要素のアクセスは魅力的というところもあります。この場合

  • 型安全性がいらないならGroovy
  • 型安全性が必要ならScala

などの言語を併用するのもよいかもしれません。

なお、Java総称型については本文で紹介した書籍のほかに以下のサイトが参考になります。

AngelikaLanger.com - Java Generics FAQs - Frequently Asked Questions - Angelika Langer Training/Consulting

*1:AがBの親クラスならA[ ]もB[ ]の親クラスという性質。

*2:実際、Javaプログラミング能力認定試験のサンプルが配列を過剰に利用するアンチパターンの好例です。SI業界(日本)のJavaプログラマーにはオブジェクト指向より忍耐力が求められている? - 達人プログラマーを目指して

*3:もちろん、それ以前に配列に対しては他の参照型のオブジェクトと異なりメソッドが普通に呼び出せないといったオブジェクト指向プログラミング上の制約ももちろんありますが。

*4:Java5以降で普通にコンパイルした場合、リフレクション用のメタ情報として総称化された型の情報はクラス定義中に残ります。したがってリフレクションAPIを用いて総称型の情報やパラメーター化型の実際のクラスを実行時に取得することができます。型消去されると言っているのは実行時のオブジェクトのデータ構造やロジックの部分についてです。この点はちょっと誤解しやすいので理解する上で注意が必要です。

*5Javaの逆コンパイラーを利用するとよいです。Free Pages Personnelles: Erreur 403 - Refus de traitement de la requête (Interdit - Forbidden)などがお勧め。

*6:Comparable<T>など総称型インターフェースを実装するクラスではブリッジコードが埋め込まれるため、型消去したクラス定義と完全に等価ではないケースがあります。

*7:このような事実を正しく理解すれば、総称型はキャストが不要だから高速になるといった理解はJavaにおいては間違いであることもわかります。

*8:ただし、new ArrayList<?>()やnew ArrayList<? extends String>()のようにトップレベルにワイルドカードパラメーターに持つパラメーター化型の生成は仕様上認められていません。一方、new ArrayList<List<?>>()はトップレベルにワイルドカードが無いためOKです。難しいですね。この制約については実質問題になるケースはないとはいえ、どうして必要か理由は実は不明確なところがあります。

*9:ただし、最近の逆コンパイラだとリフレクション情報から総称型の情報をある程度復元してしまうものもあるため、解釈には注意が必要。

*10:だから、ほとんどの場合において、実用上は@SuppressWarningsで警告を無視しても大きな問題にはならないという考え方もできます。