1. はじめに
何でも入れられる万能の箱があると想像してください。リンゴ、本、あるいはおもちゃでも構いません。Java では、この「万能の箱」に相当するのが、最も一般的な型である Object を値として保持するクラスです。Object は Java のすべてのクラスの親型なので、Object 型の変数にはどんなオブジェクトでも格納できます。
では、そのような箱が並ぶ倉庫を思い浮かべてみましょう。箱にラベルが貼られていなければ、何でも入れられますが、取り出すときには開けて中身を推測しなければなりません。ジェネリクスを使うと、状況は「ラベルの整った倉庫」に近づきます。例えば「リンゴ専用」「本専用」「工具専用」といった具合です。これなら各箱の中身が常に分かり、誤って本をリンゴ用の箱に入れてしまうこともありません。
一見すると Object に保存するのは便利に見えます。さまざまなデータ型ごとに別クラスを作る必要がないからです。ところが実際には、その「万能さ」は次のような問題を引き起こします:
- 誤りを起こしやすい。 誤って期待していないオブジェクトを箱に入れてしまうかもしれません。
- コンパイラが問題を検出できない。 Object 型がそれを許すため、何でも入れられてしまいます。
- 手動で「取り出して型変換」する必要がある。 そのような箱からオブジェクトを取り出すと再び型は Object なので、自分で必要な型に変換しなければなりません(これは 型変換、すなわち cast と呼ばれます)。型を間違えると、プログラムはエラーでその場で停止します!
簡単な例で見てみましょう。
class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
この箱を使ってみます:
Box box = new Box();
box.set("こんにちは"); // 文字列を入れた
String s = (String) box.get(); // 文字列を取り出した。問題なし
box.set(123); // 数値を入れた
// コンパイラには問題が見えない...
String t = (String) box.get(); // 実行時にエラー!
ご覧のとおり、コンパイラは最終的にエラーを招くコードを平気で通してしまいました。問題が分かったのは、プログラムを実行して「落ちた」ときだけでした。
2. 解決策:ジェネリクス
Generics(ジェネリクス)はこの問題を解決するための仕組みです。コンパイル時点で、クラスやメソッドを特定のデータ型に結び付けられる特別な構文です。言い換えると、箱に「この箱には文字列だけ」「この箱には数値だけ」といったラベルを貼るようなものです。
この結果、私たちは型安全性を得られます。コンパイラが「箱」に誤ったオブジェクトを入れることを許しません。コードをチェックして、実行前に間違いを指摘してくれます。
同じ Box クラスを、ジェネリクス対応にすると次のようになります:
class Box<Type> {
private Type value;
public void set(Type value) {
this.value = value;
}
public Type get() {
return value;
}
}
ここで Type は型パラメータです。これは任意に付ける仮の名前で(一般的には T、E、K、V などを使います)、「Box を生成するときに扱う型を指定し、コード中の Type と書かれた箇所ではその型を使う」という意味になります。
3. Generics の使い方
ジェネリクス付きのクラスのインスタンスを作るときは、山括弧 <...> の中に具体的な型を指定します。
// 文字列専用の箱を作成
Box<String> stringBox = new Box<>();
stringBox.set("こんにちは、世界!"); // OK、文字列を入れた
String s = stringBox.get(); // キャストなしで文字列を取り出す
stringBox.set(123); // コンパイルエラー! コンパイラが許してくれない。
Box<Integer> を作れば、コンパイラはその箱に数値だけが入るよう監視します:
// 数値専用の箱を作成
Box<Integer> intBox = new Box<>();
intBox.set(42); // OK、数値を入れた
Integer number = intBox.get(); // キャストなしで数値を取り出す
intBox.set("こんにちは"); // コンパイルエラー!
これでコンパイラは各箱に入るデータ型を正確に把握し、誤りから私たちをしっかり守ってくれます。
4. 例で理解する動作
ジェネリクスはクラスだけでなくメソッドにも使えます。非常に柔軟で汎用的なコードを書けるようになります。
例 1 — ジェネリクスを使ったクラス
例えば、同じ型の 2 つのオブジェクトを保持する Pair クラスが欲しいとします。ジェネリクスを使うと次のようになります:
class Pair<T> {
private T first;
private T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
使用例:
Pair<String> greetings = new Pair<String>("こんにちは", "世界");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());
Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());
1 つ目の例では greetings に文字列だけが入ることを、2 つ目では数値だけが入ることをコンパイラが保証します.
例 2 — 汎用メソッド
任意のデータ型で動作するメソッドを書くこともできます。
class Utils {
// void の前の <T> は、このメソッドが型パラメータ T を使うことを示す
public static <T> void printTwice(T value) {
System.out.println(value);
System.out.println(value);
}
}
これで任意のデータ型でこのメソッドを呼び出せ、同じように動作します:
Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);
5. ジェネリクスの利点
ジェネリクスは単なるシンタックスシュガーではなく、実際の開発上の問題を解決する強力な道具です。ここでは 3 つの主要な利点を見ていきます。
型安全性 はジェネリクスの最大の利点です。ジェネリクスがないと、コンパイラは「万能な」クラスやメソッドでデータ型を正しく使っているかを検証できません。「数値用の箱」に文字列を入れようとするような誤りは、プログラムが突然 ClassCastException を投げて「落ちる」実行時になって初めて発覚します。ジェネリクスを使えばコンパイラが型を厳密に追跡します。たとえば Box<String> には文字列だけ、Box<Integer> には数値だけを入れているかをチェックします。間違えば即座にエラーを示してくれるので、実行前に修正できます。これによりコードははるかに堅牢で予測可能になります。
コードの明快さ(不要なキャストの排除)。ジェネリクスなしの「万能箱」を思い出してください。Box box = new Box(); box.set("こんにちは"); String s = (String) box.get(); のように、箱から取り出すたびに (String)、(Integer) といったキャストを書かなければなりませんでした。ジェネリクスならその必要はなくなります。コンパイラが中身の型を把握しているので自動的に適切な型として扱ってくれます: Box<String> stringBox = new Box<>(); stringBox.set("こんにちは"); String s = stringBox.get();。コードが短くなるだけでなく、読みやすさと扱いやすさが大きく向上します。
柔軟性とコードの再利用性。ジェネリクスは、型安全性を損なうことなく、さまざまなデータ型で動作する真に汎用的なクラスやメソッドを作れます。例えば Box<T> は文字列、数値、自作クラス(Student、Car)— 何でも保存できます! StringBox、IntegerBox、StudentBox といった別クラスを書く必要はありません。1 つの汎用クラス Box<T> を書き、オブジェクト生成時に必要な型を指定するだけです。これによりコード量を減らし、重複を避け、プログラムをよりモジュール化して柔軟にできます。
6. ジェネリクスの制約
多くの利点がある一方で、ジェネリクスには知っておくべき重要な制約がいくつかあります。
プリミティブ(primitives)。int、double、boolean などのプリミティブ型は、型パラメータとしては使えません。例えば Box<int> intBox = new Box<>(); というコードはコンパイルエラーになります。代わりに、それらの「ラッパークラス」(Integer、Double、Boolean)を使います。Box<Integer> intBox = new Box<>(); のようにします。Java のコンパイラはプリミティブとラッパークラスの相互変換(int と Integer の相互変換)を自動で行えます。これをオートボクシング/アンボクシング(autoboxing/unboxing)と呼びます。
型消去(Type Erasure)。これは Java におけるジェネリクスの実装上の重要な特徴です。考え方としては、コンパイラはコンパイル時だけジェネリクスの情報を使います。型安全性を検証した後は、型パラメータに関する情報を消去します。例えば、コンパイル後(バイトコード)では Box<String> と Box<Integer> は単なる Box に見えます。つまり Java 仮想マシン(JVM)にとっては同じ型になるということです。
では何が言えるでしょうか。new Box<String>[10] のようにジェネリクスの配列を作ることはできませんし、instanceof とジェネリクスを組み合わせて、あるオブジェクトが instanceof Box<String> かどうかを調べることもできません。やや高度な話題ですが、Java を深く学ぶうえでは重要です。現時点では、ジェネリクスは本質的に「コンパイラのためのラベル」で、コード検査を助けるものだが、プログラムの実行時にはそのラベルが外される、と覚えておけば十分です。
GO TO FULL VERSION