💪

初めてのジェネリクス奮闘記

2024/11/21に公開

一般的に、クラスやメソッドを作成した場合、何らかのデータを扱う場合が大半かと思います。その際、扱うデータの型は違えど、ロジックはそのまま一般化できると利便性が高まります。ジェネリクスは取り扱うデータ型を動的に指定することができる便利機能を指しています。今回初めてジェネリクスを使った実装を行う中で、個人的に躓いた点や勉強になった部分をまとめてみました。

型パラメータ

Javaでコレクションを扱う場合、public class LinkedList<E>のように<E>でそのクラスやメソッド内で取り扱うデータ型を指定することができます。Eの部分をStringにすればStringしか受け付けませんし、自作クラスCarを指定することも可能です。<> で囲まれるものは型パラメータであり、テンプレートのような働きをします。Javaのジェネリクス(Generics)は、型安全性を強化するための仕組みとして設計されています。型パラメータは、クラスやメソッドが柔軟に異なる型を扱えるようにするためのものです。<> で囲まれた記号は、理論上はどんな記号でもOKだそうですが、慣習的には下記表にある記号を慣習的に使うようです。

記号 意味・用途
E Element(要素)
コレクション(例:List<E>Set<E>)で使われることが多い。
T Type(型)
汎用的な「型」を表す。任意の型を表現する場合に使う。
K Key(キー)
マップ(例:Map<K, V>)でキーを表す。
V Value(値)
マップ(例:Map<K, V>)で値を表す。
N Number(数値)
数値型を表すときに使われることがある。
R Return(戻り値)
メソッドの戻り値の型を表す場合に使われる。
? Wildcard(ワイルドカード)
未知の型を表す。

そもそもメソッドの引数とは違うの?

型パラメータは、引数のパラメータ(メソッドの引数)とは異なります。

引数のパラメータ(普通のパラメータ)

メソッドに値を渡すために使われるものです。例えば下記のようなパターン。

public void addIntNode(int num) {
    // num は引数パラメータ(普通の変数)
    System.out.println(num);
}

この場合、int num は 値(数値)を渡すためのものであり、メソッドが具体的な型(この場合 int)で動作することを前提としています。

型パラメータ

クラスやメソッドが扱うデータ型を動的に指定するための仕組みです。型パラメータは通常、ジェネリクス(Generics)を用いたコードで使用されます。

public class LinkedList<E> { // E は型パラメータ
    private class Node {
        E data; // data の型はジェネリクスによって決まる
        Node next;
    }

    public void add(E element) { // E の型を受け取る
        // element の型は実際に使用する型に依存
        System.out.println(element);
    }
}

ここでの E は型パラメータであり、クラスやメソッドが特定の型に縛られず、どの型でも動作するように設計されています。例えばLinkedList<Integer> を作成すると、EInteger に置き換わりますし、LinkedList<String> を作成すると、EString に置き換わるという具合です。

<E>のEってObject型?

初めてジェネリクスの説明を聞いた時の私は、この型パラメータのEって単純にObjectなのかな?って思いました。Eの部分にString指定したらStringになるし、Integer指定ならIntegerになるし。でもコードを書く上ではObject と E は違うんですね。E はジェネリクスで指定された型であり、特定の型(例:String, Integer)を表します。一方、Object はJavaのすべてのクラスのスーパークラスであり、どんな型でも扱える一般的な型です。

項目 Object E(ジェネリクス)
型の範囲 すべての型を扱える(最上位のスーパークラス) 特定の型に制限される(例:String や Integer)
型安全性 型安全性がない(キャストが必要になる場合がある) 型安全(コンパイル時に型チェックが行われる)
用途 ジェネリクスを使用しない一般的なメソッドやクラス ジェネリクスを使用した特定の型のデータ操作

ジェネリクス導入以前の話

2004年にJava 5がリリースされる前は、Javaにはジェネリック型がありませんでした。ジェネリクスが登場する前のJavaでは、型安全性を提供する仕組みがなく、汎用的なコレクションを設計するためにObject型が使われていました。Object型の利点としては、どんな型のデータでも扱えるため、柔軟性が高く、コレクションの設計が簡単でした。デメリットとしては、データの型は問わず何でもコレクションが受け入れてしまうので、データを取得する際には頻繁にキャストが必要でした。

// ジェネリクス導入以前
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // 非ジェネリックなリスト
        List list = new ArrayList();
        list.add("Hello");
        list.add(123);  // 異なる型も追加可能(型安全でない)

        // 要素を取り出すときにキャストが必要
        String str = (String) list.get(0);  // キャストが必要
        Integer num = (Integer) list.get(1);  // キャストが必要
        System.out.println(str + " " + num);
    }
}

大きな問題としては、型安全性がない点とキャストの冗長さです。リストに異なる型(例えば String と Integer)を混在させてもコンパイル時にはエラーになりません。しかし、実行時に誤ったキャストをすると ClassCastException が発生します。また、要素を取り出すたびにキャストを記述する必要があります。地味に煩わしいですよね。単純にコードもごちゃごちゃ見づらいです。

これがジェネリクスという仕組みを導入された場合、次のようにコードが実装されます。

// ジェネリクス導入後
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ジェネリックなリスト
        List<String> list = new ArrayList<>();
        list.add("Hello");
        // list.add(123);  // コンパイルエラー(型安全性が向上)

        // 要素を取り出すときにキャスト不要
        String str = list.get(0);
        System.out.println(str);
    }
}

List<String> と宣言することで、String 型以外の要素を追加しようとするとコンパイルエラーになります。加えて、ジェネリクスが型を保証するので、キャストを記述する必要がありません。コードが簡潔で読みやすくなります。

型消去による2つの目的

前項で型パラメータってObjectなのかな?と推察しましたが、あながち間違っていませんでした。型消去という仕組みでは、コンパイル時に型パラメータを指定された型として解釈し、型安全性を保証する仕組みです。そして、型安全を保証しつつ、ジェネリクスの型パラメータ(例:<E>)は実行時にObject型として扱われます。当初は、なぜわざわざ型を消去するのかという疑問がありましたが、主に下記2点が理由となりそうです。

  • 後方互換性の維持
    • ジェネリクス導入以前はObject型でコレクションなどが設計されていた
    • 型消去でObject型に戻すことで古いコードも新しいコードも共存できる
  • ランライムの効率性
    • ジェネリクスの型パラメータを保持したままだと型ごとにクラスが作成される(ex. List<String>,List<Integer>)
    • 型消去でJVMがシンプルな設計になる

ジェネリクスの目的は、型安全性を向上させ、実行時エラーを未然に防ぐことです。型消去を使えば、「型チェックはコンパイル時に行い、ランタイムでは統一的に動作」させることができるというわけです。ただ型消去によって、実行時にはジェネリクスの型情報が失われるため、型パラメータではinstanceofを直接使うことはできなくなるといった副作用もあるようです。このあたりはまだまだ調査が必要です。

境界によるパラメータ制限

ジェネリクスにおける境界とは、型パラメータ(例:<T>や<E>)に適用できる型を制限する仕組みです。Javaでは、extendsやsuperキーワードを使って、ジェネリクスの型に上限や下限を設定することができます。下記の例だとTに指定できる型はNumberクラスとそのサブクラス(例:Integer, Double)に限定されます。上位クラス(extends)や下位クラス(super)を指定する仕組みは、それぞれ上限境界と下限境界と呼ばれます。

public <T extends Number> void printDoubleValue(T number) {
    System.out.println(number.doubleValue());
}

まとめ

初めてのジェネリクス体験に最初は戸惑ったのですが、分かってしまえば、使い方自体はそこまで難しくなく大変便利な機能だと感じています。型を抽象化することにより、自由度と安全性と効率性を実現している仕組みは非常に面白いですね。本記事自体は基本的な仕組みをまとめたものですが、Javaのジェネリクスに初めて取り掛かる方の参考になれば幸いです。

参考リンク

Discussion