放課後プログラミング

調べたことや考えたことなどを忘れないために書きます。

ジェネリクスの使い方 1

最近のコードはListMapを使う際に、ジェネリクスを使用しているものが多いと思います。

public Map<String, String> getMap() {
    Map<String, String> map = new HashMap<>();
    map.put("a", "b");
    map.put("c", "d");
    return map;
}

みたいな調子に。この<>を使った記法について、理解が曖昧だったので調べました。

ジェネリクスとは?

ジェネリクスとは汎用的に実装されたクラス/メソッドをインスタンス生成時/呼び出し時に指定した型に対応付けることのできる機能のことを言う。
何が嬉しいのかはソースを見ながら考えていきます。

ジェネリクスを用いないクラス

class A {
    private Object o;
    public A(Object o){ this.o = o; }
    public Object getA(){ return o; }
}

ジェネリクスを用いたクラス

class B<T>{
    private T o;
    public B(T b){ this.o = o; }
    public T getB(){ return o; }
}

ジェネリクスを用いないクラスAでもインスタンス生成時に指定した型に対応付けできそうに見える。
ではそれぞれを使ってみてどうなるかを見ていきます。

ジェネリクスを用いないクラスA

A a = new A("A");
String s = (String)a.getA();
System.out.println(s);

// ==> A

ジェネリクスを用いたクラスB

B<String> b = new B<>("A");
String s = b.getB();
System.out.println(s);

// ==> A

次は間違えて使ってみる。

ジェネリクスを用いないクラスA

A a = new A(1);
String s = (String)a.getA();
System.out.println(s);

// ==> Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

ジェネリクスを用いたクラスB

B<String> b = new B<>(1);
String s = b.getB();
System.out.println(s);

// ==> Compilation completed with 1 error and 0 warnings in 1 sec
// Error:(20, 20) java: 不適合な型: org.tile.B<>の型引数を推論できません
//    理由: 推論変数Tには、不適合な境界があります
//      等価制約: java.lang.String
//      下限: java.lang.Integer

違いは2つあって、

  • 1つ目は、間違っていることをコンパイル時に気付けるかどうかで、クラスAを用いた場合はコンパイルを通った後、処理が実行されて、例外が飛ばされるまで気付くことはできない。クラスBを用いた場合はコンパイルエラーとなっている。思惑通りではないコードはできるだけ早い段階で間違いに気付けるのが理想です。
  • 2つ目は、間違いを指摘してくれる場所で、クラスAでは例外を飛ばすのは(String)a.getA();だけど、クラスBでエラーとなっているのはnew B<>(1);になる。「思ってるものと違うものがsetされた場所を指摘してくれる」のと、「思ってるものと違うものがsetされたものをgetしてる場所を指摘してくれる」のでは、前者の方が間違えてる箇所を探す手間が省けて楽。

更なる利点として、ジェネリクスを用いた場合は使われる型が明白であることが挙げられる。インスタンス生成時にList<String> list = ~としているとき、そのリストが文字列のリストであることは明らかだ。

境界のある型パラメータ

ジェネリクスの利点がわかったので、もう少し詳しく。

class B<T>{
    private T o;
    public B(T o){ this.o = o; }
    public T getB(){ return o; }
}

このようにジェネリクスを用いたクラスの型パラメータTにはどんな型でも入れることができる。しかし汎用的に実装したものの、その汎用性を限定的にしたいと思うことがある。そのときは「境界のある型パラメータ」を用いることで、Tに入る型を制限することができる。以下のような記法。

class B<T extends Hoge>{
    private T o;
    public B(T o){ this.o = o; }
    public T getB(){ return o; }
}

これは、型Tに
1) Hogeクラス
2) Hogeクラスを継承したクラス
3) Hogeインターフェース
4) Hogeインターフェースを継承したインターフェース
5) Hogeインターフェースを実装したクラス
のみが入りうる。
以下のように書けば、更に制限を厳しくできる。

class B<T extends Hoge & Piyo>{
    private T o;
    public B(T o){ this.o = o; }
    public T getB(){ return o; }
}

この場合は、型Tに
1) Hogeクラスを継承してPiyoインターフェースを実装したクラス
2) HogeインターフェースとPiyoインターフェースを実装したクラス
3) HogeインターフェースとPiyoインターフェースを継承したインターフェース
4) Piyoインターフェースを実装したHogeクラス
5) Piyoインターフェースを継承したHogeインターフェース
6) Hogeインターフェースを継承したPiyoインターフェース
のみが入りうる(4~6のケースは境界が冗長なので多分書き換えたほうがいい)。
Javaでは多重継承は許されないため、「HogeとPiyoのクラスを継承している」という境界は書けない(そもそもその境界に対応したクラスを書けない)。そのため、extends以降に2つ以上のクラスを書くことはできない。すなわち、2つめ以降はインターフェースになる。
まとめると以下のような感じ。

class Hoge {...}
class Hogee extends Hoge {...}
interface Piyo {...}
interface Piyoo extends Piyo {...}
class Mage extends Hoge implements Piyo {}
class Magee extends Hogee implements Piyoo {}

class B<T extends Hoge> {...}
class C<T extends Piyo> {...}
class D<T extends Hoge & Piyo> {...}
class E<T extends Piyo & Hoge> {...} // ==> NG
//境界にクラスとインターフェースが混ざる場合は、クラスを先に書くルール

B<Hoge>  b = new B<>(); // ==> OK
B<Hogee> b = new B<>(); // ==> OK
B<Piyo>  b = new B<>(); // ==> NG
B<Piyoo> b = new B<>(); // ==> NG
B<Mage>  b = new B<>(); // ==> OK
B<Magee> b = new B<>(); // ==> OK

C<Hoge>  c = new C<>(); // ==> NG
C<Hogee> c = new C<>(); // ==> NG
C<Piyo>  c = new C<>(); // ==> OK
C<Piyoo> c = new C<>(); // ==> OK
C<Mage>  c = new C<>(); // ==> OK
C<Magee> c = new D<>(); // ==> OK

D<Hoge>  d = new D<>(); // ==> NG
D<Hogee> d = new D<>(); // ==> NG
D<Piyo>  d = new D<>(); // ==> NG
D<Piyoo> d = new D<>(); // ==> NG
D<Mage>  d = new D<>(); // ==> OK
D<Magee> d = new D<>(); // ==> OK


長いので分割します。