Javaのジェネリクスを理解する

最近Javaのドキュメントを読む機会が多いが、APIの中では普段の仕事であまり使わないジェネリクスが使われていることが多く、たちまち読めなくなってしまう。時間のあるうちに、ジェネリクスの理解を深めておきたい。

ジェネリクスはなぜ生まれたか

今では当たり前のように使っている List<String>のような表記だが、最初からあったわけではない。Java5以前は、コレクション内の要素の型を1つに指定する術はなかった。コレクションのadd/getメソッドは、それぞれの引数や戻り値をObject型として取り扱うようになっていたため、要素を取り出すときはキャストするようにしていたそうだ。
しかしその場合、コレクション内に別の型の要素を混入させることができる。さらに悪いことにコンパイルは通ってしまい、実行時にエラーが起こることになる。せっかくの型情報を活かせず、Object型としてしか扱えないことを、当時は「型の損失」と言っていたようだ。

public class Main {
    public static void main(String[] args) {
        List students = new ArrayList();
        students.add(new Student(1, "John"));
        students.add("hoge"); // Student型でない要素を混入させられる
        Student s0 = (Student) students.get(0); 
        Student s1 = (Student) students.get(1); // 実行時にキャストに失敗して落ちる
    }
}

class Student {
    int studentId;
    String name;
    
    public Student(int studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }
}

この問題を解決するために、ジェネリクスが導入された。宣言に一つ以上の型パラメータを持つクラスやインターフェースが登場したのである。例えばListインターフェースの正式な名前は List<E>となっており、型パラメータEを指定することで、List<String>List<Student>などを定義できる。

非境界ワイルドカード

List<?>のように定義すると、任意の型のリストを取り扱うことができる。

public class Main {
    public static void main(String[] args) {
        List<String> strList = new ArrayList<>(Arrays.asList("a", "b", "c"));
        List<Integer> integerList = new ArrayList<>(Arrays.asList(1, null));

        System.out.println(getSize(strList)); // 3
        System.out.println(getSize(integerList)); // 2
    }
    static int getSize(List<?> list) { //strList, integerListの両方を引数に渡せる
        return list.size(); 
    }
}

単にListとすることとの違いは、このList<?>にはnull以外の一切の値を追加できないことにある。このため、異なる型の要素が混入することはない。そのかわり、List<?>に何の型が入るのかコンパイラは知らないため、Object型以外の型を指定して値を取り出そうとするとコンパイルエラーとなる。size()などリスト内の要素の型に関わらない、Listインターフェースで定義されたメソッド以外は使いにくいだろう。

境界ワイルドカード

List<? extends Student>とすると、リストに入る型がStudent自身かそのサブタイプに制限される(すべての型は自分自身のサブタイプでもあるため、Student型も含まれる)。例えば、StudentのサブタイプであるJuniorとSeniorという型があるとき、以下のようにStudent型で要素を受け取ることが可能となる。ただし 依然としてリストへの追加は不可能であることに注意。

public class Main {
    public static void main(String[] args) {
        List<Junior> juniorStudents = new ArrayList<>(Arrays.asList(
                new Junior(1, "John"),
                new Junior(2, "Jack")
                ));
        List<Senior> seniorStudents = new ArrayList<>(Arrays.asList(
                new Senior(3, "Jay"),
                new Senior(4, "Jude")
            ));
        System.out.println(getFirstStudent(juniorStudents).name); // John
        System.out.println(getFirstStudent(seniorStudents).name); // Jay
    }
    static Student getFirstStudent(List<? extends Student> students) {
        return students.get(0); //要素をStudent型で受け取れる
    }
}

class Student {
    int studentId;
    String name;
    
    public Student(int studentId, String name) {
        this.studentId = studentId;
        this.name = name;
    }
}

class Junior extends Student {
    public Junior(int studentId, String name) {
        super(studentId, name);
    }
}

class Senior extends Student {
    public Senior(int studentId, String name) {
        super(studentId, name);
    }
}

なぜList<Student>だけでサブタイプを扱えないかというと、List<Junior>List<Student>のサブタイプとはみなされないため。もしそうでない場合、同じList<Student>型のリストにJunior型とSenior型を混在させることができてしまう。 境界ワイルドカードは、一つのリストに型を混在させないようにしつつ、複数のリストを抽象的に扱う工夫がなされた方法といえる。
なお、逆に List<? super Junior>という書き方をすれば、Junior自身かそのスーパータイプのみに要素の型を制限できる。

ジェネリックメソッド

先程の境界つきワイルドカードは引数の型を柔軟に扱うためのものだったが、メソッド定義の時点でジェネリック型を扱いたいときもある。最も単純なジェネリックメソッドは以下のようになる。

public class Main {
    public static void main(String[] args) {
        List<String> strList = new ArrayList<>(Arrays.asList("a", "b", "c"));
        List<Integer> integerList = new ArrayList<>(Arrays.asList(1, null));

        System.out.println(getSize(strList)); // 3
        System.out.println(getSize(integerList)); // 2
    }
    static <T> void printElem(List<T> list) {
        for (T e in list):
             System.out.Println(e);
    }
}

ジェネリックメソッドは、ジェネリックではないクラスで使われる前提があるため、まず<T>で型パラメータを宣言する。このメソッドは、listの要素の型に関わらず、要素のサイズを取得して返している。
なお、クラスの定義の時点でジェネリック型を使いたい場合は、 class StudentList<T extends Student> のように書くことができる。

?とTの違い

ここまで見ると、ワイルドカードと型パラメータは似た働きをするように思えるが、いくつか違いもある。
例えば、以下のようなジェネリックメソッドは動作する。

public <T> List<T> moveFirstElem(List<T> list, List<T> anotherList) {
        anotherList.add(list.get(0));
        return anotherList;
}

しかし、以下は動作しない。ワイルドカードが使われたリストに要素を追加することはできないためである。

public List<? extends Student> moveFirstElem(List<? extends Student> list, List<? extends Student> anotherList) {
        anotherList.add(list.get(0));
        return anotherList;
}

逆に、型パラメータを使う場合は <T super SuperType>のようにsuperを使った指定はできない。