2️⃣

Javaにおける共変性・反変性・非変性について②

2023/11/07に公開

はじめに

前回は非変性までを扱いました。非変性な世界ではポリモーフィズムが利用できないことがわかった訳ですが、今回は今一度ポリモーフィズムについて考えてみたいと思います。

ポリモーフィズムの世界

ポリモーフィズムは何の世界に属しているのでしょうか。ポリモーフィズムが許容される世界はどんな世界でしょうか、考えてみましょう。典型的なポリモーフィズムを利用したコードを例として挙げておきます。

Sample.java
class Sample {
  private final Number value;
  Sample(Number value) {
    if(value == null) throw new IllegalArgumentException();
    this.value = value;
  }
  Number getValue() {
    return this.value;
  }
}
Main.java
class Main {
  public static void main(String... args) {
    Sample sample = new Sample(Integer.of(2));
    Number num = sample.getValue();
  }
}

以上のコードでは、コンストラクタの引数やgetValueメソッドの戻り値、フィールドnumにおいて、Number型データとしてInteger型データを扱っています。これが認められる理由はJavaではポリモーフィズムが利用できるからです。クラスの継承関係について親子関係が結ばれているなら、親型データとして子型データを扱えるのでしたね。この仕組みによって実際に渡されたデータ型による処理の制御が可能になったり、プログラムを保守しやすくなったりと様々なメリットがあった訳です。

ではこの仕組みはどんな世界の住人なのでしょう。まあ、「非変性」ではなかったことがわかっているので、「共変性」、「反変性」のどちらかに属するか、両方に属するかだろうなという予想が立ってしまうでしょうが、一旦見なかったことにして、どこの世界に所属するのか予想してみて下さい。

予想できたでしょうか。答えは 「共変性」 です。正確に言えば、部分ごとに別の世界にあるとなるのかもしれませんが、比喩表現なのでそこまでの厳密さは一旦求めないこととしましょう。では共変性がどのようなものなのか確認して行きます。

共変性

前項のコードと同じですが、もう一度以下のコードを確認して下さい。

Sample.java
class Sample {
  private final Number value;
  Sample(Number value) {
    if(value == null) throw new IllegalArgumentException();
    this.value = value;
  }
  Number getValue() {
    return this.value;
  }
}
Main.java
class Main {
  public static void main(String... args) {
    Sample sample = new Sample(Integer.of(2));
    Number num = sample.getValue();
  }
}

このコードが問題なく実行できる理由、それは前述の通り「クラスの継承関係について親子関係が結ばれているなら、親型データとして子型データを扱えるから」でした。このように、スーパータイプのデータ型でそのサブタイプのデータ型を扱うことができるような設計、性質を 「共変性(Covariance)」 と言います。Javaでポリモーフィズムが利用できる理由はまさにこの共変性という特性のおかげだった訳です。共変性を実現できない世界ではポリモーフィズムは利用できません。つまり、非変性の世界同様Object obj = new String("text");という記述のコンパイルが認められなくなってしまうということになります。

共変戻り値

前回、◯変性というワードをオーバーライドと一緒に使っているところを見たという人にちょっと待ってて下さいとお願いしたかと思いますが、ここでその話をしておきます。「共変戻り値」というものについての話になるのですが、説明の前にいつも通りコードの確認から進めていきましょう。

Parent.java
class Parent {
  Number method(int i){
    return Integer.valueOf(i);
  };
}
Child.java
class Child extends Parent {
  @Override
  Integer method(int i) {
    return Integer.valueOf(i + 5);
  }
}

注目して欲しいのは、Childクラスでオーバーライドされているメソッドの戻り値のデータ型です。基本的にメソッドをオーバーライドする際はメソッドのシグニチャが一緒である必要があるのですが、このコードは問題なくコンパイル及び実行ができます。Javaではメソッドのオーバーライドをする際に、親メソッドの戻り値のデータ型のサブタイプであれば戻り値のデータ型を変更することが認められているのですね。このような戻り値を 共変戻り値 と言います。この仕組みがなぜ許容されるのかは、メソッドを呼び出す際のコードを見ればわかるかと思います。

Main.java
class Main{
  public static void main(String... args) {
    Parent parent = new Child();
    Number num = parent.method(5);
  }
}

このコードでは、Parent型の変数parentにChild型のインスタンスの参照を代入しています。その後に変数parentを通してmethodの呼び出しを行なっている訳ですが、ここで、methodの戻り値はInteger型です。これはparentが参照しているインスタンスはChild型であるため、Childクラスに定義されたオーバーライド後の定義が動的ディスパッチによって実行されるからです。numという変数はNumber型なので戻り値のデータ型とずれてしまいますが、幸いInteger型はNumber型のサブタイプであるので問題なく実行できるのですね。

では、次のコードのようにオーバーライドで再定義されたmethodの戻り値がString型であった場合どうなるでしょう。

Child.java
class Child extends Parent {
  @Override
  String method(int i) {
    return Integer.valueOf(i + 5);
  }
}

parentという変数はParent型であるため、戻り値を代入する変数のデータ型はその定義に合わせNumberと指定する必要があります。varを用いた型推論であっても、コンパイル時にNumberと書き換わるため同じことです。ですが、実際のインスタンスはChild型でmethodの戻り値はStringであると再定義されているため、Number型と互換性がなく例外が発生してしまうことが考えられます。このような例外を発生させないために、戻り値のデータ型を変更する場合は元となるデータ型のサブタイプとすることが義務付けられている訳です。

上限境界ワイルドカード

ジェネリクスで指定したデータ型に対しては非変性として動くように設計されているということや、その理由が例外発生を防ぐためであるというのは前述の通りですが、やはり変数宣言時に指定した型でないと利用できないというのはポリモーフィズムの立場からはあまり好ましいものとは言えません。そこで、ある条件の下であれば変数宣言時に確定させずともジェネリクスを利用したデータ型を利用していいですよという仕組みが導入されています。その仕組みを ワイルドカード と呼ぶのですが、境界の有無によって大きく2種類に分かれており、境界を有するもの中でさらに2種類に分かれています。非境界ワイルドカードは様々な理由から利用されることはほとんどありませんので、境界を有するものを紹介したいと思います。

前述の通り境界を有するワイルドカードは2種類あるのですが、それぞれどの点で違うのかといえば、共変性を実現するための仕組みなのか、反変性を実現するための仕組みなのかという点です。現状共変性についてのみ取り扱いましたので、まずは共変性を実現するためのワイルドカードを見て行きましょう。以下のコードを確認して下さい。

上限境界ワイルドカード
List<? extends Number> list = new ArrayList<>();
list = new ArrayList<Integer>();

このコード内のジェネリクスに指定された「?」がワイルドカードです。文字通り型がわからないよということを意味しています。この「?」のみを記述するものを非境界ワイルドカードと呼ぶのですが、今回は? extends Numberとなっているため非境界ワイルドカードではありません。このコード例のように「extends」を利用するワイルドカードを、 上限境界ワイルドカード と呼びます。「extends」を利用していることから何となく想像できる方もいるかもしれませんが、上限境界ワイルドカードは「extends」の後ろに宣言したデータ型の子クラス型であれば扱うことが出来ますよという意味になります。言い換えれば、ジェネリクスを持つデータ型の変数に共変性を持たせられますよということですね。そのため、上記コードの変数「list」にはInteger型を管理するArrayListを代入することができるのです。

さて、この便利そうに思える上限境界ワイルドカードですが万能ではありません。どういうことかというと、メンバメソッドの引数のデータ型を型パラメータを利用して定義している場合に、そのメンバメソッドには引数としてnullのみが認められるようになってしまうのです。少し難しいかと思いますので、具体的なコードを見てみましょう。

Sample.java
class Sample<T> {
  private T value;
  Sample(T value) {
    if(value == null) throw new IllegalArgumentException();
    this.value = value;
  }
  T getValue() {
    return this.value;
  }
  void setValue(T value) {
    if(value == null) throw new IllegalArgumentException();
    this.value = value;
  }
}
Main.java
class Main {
  public static void main(String... args) {
    Sample<? extends Number> sample;
    sample = new Sample<Integer>(20);
    Number num = sample.getValue();// 問題なし
    sample.setValue(null); // 問題なし
    sample.setValue(Integer.valueOf(30)); // コンパイルエラー
  }
}

このコードではSample型の定義にジェネリクスを利用しており、変数宣言の際の型パラメータの指定に上限境界ワイルドカードを利用しています。コード内コメントアウトの通り、sample.setValue(null);は問題なくコンパイルできますが、sample.setValue(Integer.valueOf(30));の方はコンパイルできません。上限境界ワイルドカードを利用すると、戻り値のデータ型の指定は必ず上限として定義されたデータ型の子クラスであることが確定しているため、問題なくNumber型の変数で戻り値を受け取ることができます。しかしながら、前述の通り上限境界ワイルドカードを利用すると、引数にジェネリクスで指定するデータ型を利用しているメンバメソッドには、nullのみを引数として渡せるようになってしまいます。よって具体的な値を代入しようとしたときにコンパイルエラーが発生してしまうのです。

では、なぜこのようなコンパイルエラーが発生することになっているのでしょう。これは上限境界ワイルドカードの利用では型の指定が完全ではないことが原因となっています。名前の通りあくまでも継承関係の上限しか指定できておらず、その条件のもとに不定であるという訳ですね。よって、「sample」という変数の参照先インスタンスはNumber型とその子クラス型であれば、何でもジェネリクスに指定できるということになります。以下のケースを考えてみましょう。

class Main {
  public static void main(String... args) {
    Sample<? extends Number> sample = new Sample<Integer>(20);
    sample.setValue(Double.valueOf(30.0));
  }
}

上限境界ワイルドカードを利用した変数を通したメンバメソッドの呼び出しで引数に渡せるデータに制限がない場合、上記のコードのコンパイルが通ることになります。その結果Integer型フィールドにDouble型データを代入しようとすることで例外が発生することとなるでしょう。このような事態を避けることを目的として、nullのみを引数に渡すことができるということになっているのです。

まとめ

今回はポリモーフィズムの確認から共変性、そしてその共変性に関わる仕組みについて扱いました。共変性とは「親クラス型の変数であれば、自身のデータ型に加えて子クラス型のデータを扱うことができる」という性質です。Javaをある程度触ったことがある方なら馴染みやすい性質だったのではないでしょうか。

次回は反変性について扱って、最後に非境界ワイルドカードがなぜ不便なのかに触れてこのシリーズを終えたいと思っています。

Discussion