Java におけるタイプセーフとジェネリクスの微妙な関係


ジェネリクスの 不変・共変・反変 といった話は、なんとなく分かった気になって流してしまう方が多いのではないでしょうか?


Java に限った話ではないですが、実際、ジェネリクスが絡んだタイプセーフ性の話題は混乱しやすく、理解しにくいものだと思います。


ここでは Java を題材に ジェネリクスの 不変・共変・反変 について、なるべく分かりやすく説明してみたいと思います。



はじめに

以下のようなクラスを考えます。

public class Animal { }

public class Dog extends Animal { }

public class Beagle extends Dog { }
public class Poodle extends Dog { }


図にすると以下のようになります。

f:id:Naotsugu:20171111015920p:plain

当たり前な話

最初は当たり前な例からです。


型パラメータとして Dog を指定した List<Dog>インスタンスを格納してみます。

List<Dog> dogs;

dogs.add(new Dog());
dogs.add(new Beagle());
dogs.add(new Poodle());


List<Dog> は Dog が入る入れ物なので、Dog のインスタンスも Beagleのインスタンスも Poodle のインスタンスも入れることができます。


f:id:Naotsugu:20171111015939p:plain


これは、Beagle も Poodle も 犬の一種なので Dog として扱えるためとなります。

Dog dog1 = new Beagle();
Dog dog2 = new Poodle();


List<Dog> に入れることができるのは当たり前の話ですね。

ジェネリック型の継承関係

では次に List<Dog>List<Poodle> などのパラメータ化された型(parameterized type) 自身の関係について考えてみましょう。


以下の型を考えます。

List<Dog> dogs;
List<Beagle> beagles;


先程示した Dog dog1 = new Beagle() と同じように考えると、直感的には以下のように扱えそうな感じがします。


List<Dog> dogs = new ArrayList<Beagle>(); // これはコンパイルエラー


Java ではエラーになりコンパイルできません。


同様に以下の例もコンパイルエラーになります。

List<Beagle> beagles = new ArrayList<Dog>(); // これもコンパイルエラー


Java では、パラメータ化された型(parameterized type) 同士は、Dog や Beagle といった型パラメータに継承関係があった場合でも、全く別の型として扱われます。

これを 不変(invariant) であると言います。


そして List<Dog> dogs = new ArrayList<Beagle>() のような関係が成り立つ場合を 共変 (covariant)

List<Beagle> beagles = new ArrayList<Dog>() のような関係が成り立つ場合を反変 (contravariant) と呼びます。
図にすると以下のようになります。

f:id:Naotsugu:20171111020001p:plain


共変反変はそれぞれ鏡写しのような特徴があり、それぞれにタイプセーフ性で問題となるケースがあります。


以下に順に見ていきましょう。


共変(covariant)の問題点

不変Javaジェネリクスですが、共変だったと仮定して何が起こるかを見てみましょう。


もし List<Beagle>List<Dog> のサブクラスであった場合は以下のように扱うことができるということになります。


List<Dog> dogs = new ArrayList<Beagle>();


この定義が有効であった場合、以下のようなことが可能になります。


dogs.add(new Dog());
dogs.add(new Poodle());


犬の入れ物なので、Dog や Poodle が入れられますが、実際の入れ物は new ArrayList<Beagle>() としてインスタンス化した Beagle 専用の入れ物です。

f:id:Naotsugu:20171111020018p:plain

Beagle の入れ物に Dog や Poodle を格納できてしまうとタイプセーフではなくなってしまいます。


つまり、共変の場合には格納操作でランタイムエラーが発生する可能性があります。

List<Beagle> beagles = new ArrayList<Beagle>();
List<Dog> dogs = beagles;
dogs.add(new Poodle()); // Beagle の入れ物に Poodle を格納できてしまう ランタイムエラー


このような問題点があるため、Javaジェネリクス型は共変にはなっていません。

共変(covariant) が妥当なケース

先程の例では、不正なインスタンスを入れられてしまう点が問題でした。

今度は入れ物から取り出す視点で見てみましょう。


予め Beagle を格納済みのリストを用意します。

List<Beagle> beagles = new ArrayList<>();
beagles.add(new Beagle());


共変と仮定すれば、List<Beagle>List<Dog> のサブクラスとなるため以下のように扱うことができます。


List<Dog> dogs = beagles;
Dog dog = dogs.get(0); // Beagle が取り出せる OK


Dog の入れ物からDogのサブクラスである Beagle が取り出せるのは問題ありません。Beagle は犬だからです。

f:id:Naotsugu:20171111020036p:plain

共変とした場合、値を取り出す立場から見た場合には問題は発生しません。


しかし先程見たように、値を格納する立場から見た場合にはタイフセーフを脅かす(ランタイムエラーの原因となるコードを書けてしまう)ということになります。

反変 (contravariant) の問題点

では今度は反変について見てみましょう。ここでも Javaジェネリクスが反変だったと仮定して何が起きるかを見ていきます。


Dog のリストを用意して、予め Poodle を格納します。

List<Dog> dogs = new ArrayList<>();
dogs.add(new Poodle());


反変とするので、List<Dog>List<Beagle> のサブクラスであり、以下のように扱えます。


List<Beagle> beagles = dogs;
Beagle beagle = beagles.get(0); // Poodle が出て来る


Beagle の入れ物から(予め格納された) Poodle が出てきてしまいました。Beagle に代入はできないのでこの時点でランタイムエラーになってしまいます。


f:id:Naotsugu:20171111020054p:plain

先程の共変の例では取り出す立場からみた場合には問題は発生しませんでしたが、反変の場合は取り出す立場で問題が発生しました。

反変 (contravariant) が妥当なケース

最後に反変を、格納する立場で見てみましょう。


もし List<Dog>List<Beagle> のサブクラスであった場合は以下のように扱うことができるということになります。

List<Beagle> beagles = new ArrayList<Dog>();


Beagle のインスタンスを格納してみましょう。


beagles.add(new Beagle());


先程の例ではタイプセーフが破綻していましたが、こちらの例では問題ありません。ArrayList<Dog> には犬の一種である Beagle を格納できても何らおかしなことは無いためです。


f:id:Naotsugu:20171111020110p:plain


ここまでのまとめ

ジェネリクスの型には3つの変位があります。


  • 不変(invariant)
    • List<Dog>List<Beagle> には関係性がない
  • 共変(covariant)
    • List<Beagle>List<Dog> のサブタイプ
  • 反変(contravariant)
    • List<Beagle>List<Dog> のスーパータイプ


f:id:Naotsugu:20171111020130p:plain


共変は、格納する視点で見ると問題がある


反変は、取り出す視点で見ると問題がある


よって Java でのジェネリクス不変 となっている


ということになります。


ジェネリクスが不変だと、、

ジェネリクス不変であり、型同士の互換性が無いとすると、ジェネリクスを引数に取る汎用的なメソッドを定義したい場合などで困ったことが起こります。


例えばオスの犬をカウントするメソッドを定義したとします。

public int countMale(List<Dog> dogs) {
  int count = 0; 
  for (Dog dog : dogs) {
    if (dog.sex == MALE) count++; 
  }
  return count;
}


不変なので List<Dog>List<Animal> とも List<Beagle> とも型の互換性がありません。


そのため犬の一種である Beagle のリストを渡そうとしてもコンパイルエラーになってしまいます。

List<Beagle> beagles;
// ...
countMale(beagles); // List<Dog> しか受け付けないためエラー


今後増えるであろう犬の種類に応じて、それぞれ同じメソッドを作らなくてはならなくなります。


しかもタイプイレイジャの関係でオーバーロードできないため、メソッド名も変えなくてはなりません( List<Dog>List<Beagle>コンパイル後は単にList になり同じシグネチャになってしまうため異なるメソッド名にする必要がります )。


これでは困ります。場合によって共変として扱いたいことが良くあるのです。


ジェネリクスに共変性を持たせる

上限境界ワイルドカード(upper bounded wildcard type) を使うとジェネリクスに共変性を持たせることができるようになります。


List<? extends Dog> のように型定義します。


先程の例で言えば以下のようになります。

public int countMale(List<? extends Dog> dogs) {
  int count = 0; 
  for (Dog dog : dogs) {
    if (dog.sex == MALE) count++; 
  }
  return count;
}


上限境界ワイルドカードにすると、Dog を継承した何らかの要素が入った入れ物を受け付けることができるようになり、先程コンパイルエラーとなっていたコードが有効になります。


List<Beagle> beagles;
// ...
countMale(beagles); // 今度はコンパイルエラーにはならない


もちろんPoodleのリストもDogのリストも受け付けることができます。


countMale(new ArrayList<Poodle>());
countMale(new ArrayList<Dog>());



さて、共変は互換の無いインスタンスを格納できてしまうという問題がありましたが 境界ワイルドカードの場合はどのようになるでしょうか。
先程のメソッドの中でこっそりPoodleのインスタンスを格納してみましょう。

public int countMale(List<? extends Dog> dogs) {
  dogs.add(new Poodle()); // コンパイルエラー
  // ...
}


この場合はコンパイルエラーになります。不正なインスタンスを格納できないため、共変の場合に見た問題が発生せず、タイプセーフになります。めでたし。


補足しておくと、ワイルドカート ? を使った場合には、その要素に追加できるのは null のみとなります。

List<? extends Dog> dogs = new ArrayList<Beagle>();

dogs.add(new Beagle()); // コンパイルエラー
dogs.add(new Poodle()); // コンパイルエラー
dogs.add(new Dog());    // コンパイルエラー
dogs.add(null);         // OK


タイプセーフを保ったまま追加可能なものは null のみということです(ヌルポでランタイムエラーになるのは置いておいたとして)。


ジェネリクスに反変性を持たせる

下限境界ワイルドカード(lower bounded wildcard type)を使うとジェネリクスに反変性を持たせることができるようになります。


List<? super Dog> のように型定義します。


上限境界ワイルドカード型では値を格納することが出来ませんでしたが、下限境界ワイルドカードとすることで値を格納することができるようになります。

public void fillDogs(List<? super Dog> dogs) {
  dogs.add(new Beagle());
  dogs.add(new Poodle());
  dogs.add(new Dog());
}


Dog またはそのスーパークラス専用の入れ物を引数に取るため、Dog またはそのサブクラスを格納することができます。


メソッドの呼び出し元は、以下のように呼び出すことができます。

List<Animal> animals = new ArrayList<>();
fillDogs(animals);

List<Dog> dogs = new ArrayList<>();
fillDogs(dogs);


動物の入れ物、犬の入れ物なので、Beagle や Poodle が入っていてもおかしくはありませんね。



さて、反変は取り出す視点で見ると問題がありました。

下限境界ワイルドカードの場合はどうでしょうか。


値を取り出してみましょう。

public void lower(List<? super Dog> dogs) {
  Animal animal = dogs.get(0); // コンパイルエラー
  Dog dog       = dogs.get(0); // コンパイルエラー
  Beagle beagle = dogs.get(0); // コンパイルエラー
  Object object = dogs.get(0); // OK
}

下限境界ワイルドカード型から値を取り出すと Object 型が返ってきます。つまり、実際の型は規定できないということになり、必要であればキャストするなどして使わなくてはならず、タイプセーフは保たれています。


何が入っているかわからないからObjectで返しておくわ。 ということですね。


境界ワイルドカード型のまとめ

境界ワイルドカード型の上限・下限が表す範囲は以下になります。


f:id:Naotsugu:20171111020205p:plain


それぞれ、格納できるもの、取り出せるものは以下のようになります。


  • Dog のサブクラスである Beagle を Dog として取り出すことができる。

f:id:Naotsugu:20171111020218p:plain


- Dog のサブクラスを格納することができる。取り出す場合は Object としてしか取り出せない。

f:id:Naotsugu:20171111020236p:plain


結局これらは頭が混乱しやすいので PECS などという名前で呼ばれます。つまり

  • プロデューサ(Producer) は Extends
  • コンシューマ(Consumer) は Super


忘れた場合には java.util.Collections のコピー処理を見たほうがスッキリします。


public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    ListIterator<? super T> di = dest.listIterator();
    ListIterator<? extends T> si = src.listIterator();
    for (int i = 0; i < src.size(); i++) {
        di.next();
        di.set(si.next());
    }
}


  • src が生産者、つまりコピー対象の要素を提供し
  • dest が消費者、つまりコピー対象の要素を受け入れる


f:id:Naotsugu:20171111020314p:plain


というか日本語よりソース見た方が分かりやすいですね。



以上で本題は終了です。以下は雑多な余談です。


ジェネリック型のキャスト

ジェネリック型は無理やりキャストしようとしてもできません。


List<Beagle> beagles = new ArrayList<>();
List<Dog> dogs = (List<Dog>) beagles; // これはコンパイルエラー


無理にキャストする場合は以下のようにするとコンパイルエラーは出なくなります。

@SuppressWarnings("unchecked")
List<Dog> dogs = (List<Dog>)(List<?>) beagles;

安全性は置いておいて。


境界型パラメータ

上限境界ワイルドカードで共変性を導入できました。同じことが境界型パラメータ(bounded type parameter) を使って定義することができます。


境界型パラメータはワイルドカート(? ) ではなく、型パラメータを使って <T extends Dog> のように書いたものです。


以下の2つのメソッドは等価となります。

public void upper1(List<? extends Dog> dogs) {
  dogs.add(null);
  Dog val = dogs.get(0);
}

public <T extends Dog> void upper2(List<T> dogs) {
  dogs.add(null);
  Dog val = dogs.get(0);
}


いずれも null しか追加できず、値の取り出しは Dog が得られ、等価な振る舞いをします。

呼び出し側でも List<Dog>List<Beagle> を引数に渡してメソッドを呼び出すことができます。


上記例では違いはありませんが、以下のように境界型パラメータとした場合には型パラメータ T を複数箇所で使用し、戻り値や他の引数の制約として使うことができます。

public <T extends Dog> T firstElement(List<T> dogs) {
  T val = dogs.get(0);
  return val;
}


ただし同じような書き方で反変性を表現したくても、以下はコンパイルエラーになります。

// こういう書き方は文法上できない
public <T super Dog> void lower(List<T> dogs) {
}


なかなかややこしいですよね。


配列は共変

Javaの配列は共変です。


Arrays.sort を見るとObject配列を受けるシグネチャとなっています。

public static void sort(Object[] a) {
    ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
}


配列は共変なので、あらゆる配列型を sort することができます。


Javaの父である James Gosling いわく、まさにこのような汎用的なメソッドを用意したかったため Javaの配列は共変にしたとのこと。



共変なので、最初に見たような問題が発生します。

String[] stringArray = new String[10];

Object[] objectArray = stringArray;
objectArray[0] = 10; // ここでランタイムエラー


この点において批判の的になってましたが、ジェネリクスが無かった時代の言語設計上の妥協点なのだと思います。