2020/03/28

型システムにおける共変性と反変性

共変、反変、不変とは?

オブジェクト指向言語で親クラスに対して子クラスを定義する際、親子で型が違っても、型システムを壊さないパターンがいくつかあります。そのパターンの中で出てくるのが、共変、反変、不変というキーワードです。
共変とは、広い型を持つ親クラスから、狭い型を持つ子クラスを導出する際に、合わせて広い型から狭い型へ変わることを指します。反変とは逆に、狭い型から広い型へ変わること、不変とは型が変わらないことを指します。

共変戻り値型 (Covariant Return Type) とは?

共変が許される例として、共変戻り値型というものがあります。
親クラスに存在するあるメソッドを子クラスでオーバーライドすることを考えます。
そのときに、オーバーライドしたメソッドの戻り値の型を親クラスより限定された型に置き換えることを共変戻り値型と呼びます。
共変戻り値は、親クラスと子クラスの互換性を損ねません。

次のクラス図を見てください。食事には、洋食と和食というサブクラスがあり、和食のサブクラスとして、そばと寿司があります。それらに対して、飲食店というクラスとそのサブクラス群があります。飲食店は get食事() というメソッドを持っていて、呼び出すと、食事のインスタンスを返します。

飲食店のサブクラスとして、洋食屋と和食屋が定義されています。これらの get食事() はオーバーライドされていて、それぞれ洋食と和食を返しますが、get食事() の戻り値の型がそれぞれ洋食と和食に変更されています。そば屋と寿司屋も、そば型、寿司型の食事を返すようになっています。
飲食店→和食屋→寿司屋と限定された型が定義されていくのに合わせて、get食事() の戻り値の型も食事→和食→寿司と限定された型に変わっていきますので、共変的です。これが、共変戻り値型と呼ばれるものです。

Java のプログラムで、使用例を見てみましょう。

public class 客 {
 public static void main(final String[] args) {
  客 p = new 客();
  飲食店 s;

  s = new 洋食屋();
  p.食べる(s);

  s = new 和食屋();
  p.食べる(s);

  s = new そば屋();
  p.食べる(s);

  s = new 寿司屋();
  p.食べる(s);
 }

 public void 食べる(飲食店 shop) {
  食事 x = shop.get食事();
  System.out.println(x.getClass().getSimpleName() + "を食べました。");
 }
}

客クラスは、食べる() メソッドを持っていて、引数に渡された飲食店から get食事() して、出された物を食べます。
どの飲食店のサブクラスも、get食事() が返すのは食事型を持つことには変わりないため、食べる() のように戻り値を食事型として取り扱えば問題ありません。共変戻り値型が親クラスと子クラスの互換性を損ねないというのは、このことです。

ちなみに Java で共変戻り値型が使えるようになったのは、バージョン 1.5 以降です。

反変引数型 (Contravariant Parameter Type) とは?

反変の例としては、反変引数型というものがあります。
あるメソッドをサブタイプでオーバーライドするときに、そのメソッドの引数の型をより汎化された型に置き換えることを反変引数型と呼びます。

具体的にクラス図で見てみます。共変戻り値型のときの例に対し、set代金() というメソッドを加えました。飲食店では、現金を引数として受け取ることができます。


和食屋では、set代金() をオーバーライドして、引数の型を金券に変更しました。金券は現金の親クラスなので、広い型に変更したことになります。和食屋の set代金() は、金券も商品券も代金も引数として指定可能になりました。このようにしても、飲食店というクラスの互換性は生きています。和食屋を飲食店という型で捉えたときには、set代金() には現金型の引数が渡せれば十分です。和食屋は現金型も含めたより広い型を受け取るようにオーバーライドしただけなので、互換性は維持されている、というわけです。
寿司屋では、さらに支払手段をいうより広い型に広げて、クレジットカードも対象に入れています。これでも和食屋、飲食店との互換性は維持されています。

このように、反変引数型もスーパータイプの互換性を損ねないのですが、Java では今でもできません。C++ でもサポートされていません。

共変引数の話

あるメソッドをサブタイプでオーバーライドするときに、そのメソッドの引数の型をより限定された型に置き換えることで、特定の型についての振舞いのみオーバーライドできないか、という話。
例えば、Java で equals() のオーバーライドで試してみましょう。以下のようなコードを書きました。
public class CovariantParameter{
    private String string;
    public CovariantParameter(String string) {
        this.string = string;
    }
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((string == null) ? 0 : string.hashCode());
        return result;
    }
    public boolean equals(Object obj) {
        return false;
    }
    public boolean equals(CovariantParameter obj) {
        return string.equals(obj.string);
    }
}

equals() の引数に CovariantParameter 型のオブジェクトが渡されたときには string.equals() を使い、そうでないときには false を返すものと期待して、書いてみました。
しかし、Java では共変引数によるオーバーライドはサポートされていません。このコードは期待通りには動かないのです。
Java では、equals(Object) のみが Object#equals(Object) のオーバーライドになります。equals(CovariantParameter) は単なるオーバーロードで、Object#equals(Object) のオーバーライドになりません。

……分かりにくいですね。もうちょっと詳しく説明します。
以下のように呼び出した場合は、equals(CovariantParameter) が呼び出されます。
CovariantParameter a1 = new CovariantParameter (new String("a"));
CovariantParameter a2 = new CovariantParameter (new String("a"));
a1.equals(a2); // true


しかしこれはメソッドのオーバーロードの規則にしたがって equals(CovariantParameter) が選択されただけであって、equals(Object) のオーバーライドとしては処理されていません。その証拠に、以下のように呼び出した場合は、equals(Object) だけがオーバーライドとして認識されていて、期待通りの動作をしていないことが分かります。
Object a1 = new CovariantParameter (new String("a"));
Object a2 = new CovariantParameter (new String("a"));
a1.equals(a2); // false

Java における配列の共変性

Java の配列は共変性を持っています。
例えば、Integer は Number のサブクラスです。それに従って、Integer[] は共変的に Number[] のサブタイプと位置付けられています。

共変性があるため、以下のような代入ができます。
Integer[] integers = new Integer[256];
Number[] numbers = integers;

しかしこの後、以下のようなコードが実行されると問題が発生します。
numbers[0] = new Long(1);

Integer[] は Number[] のサブタイプですし、Long も Number のサブタイプですから、型の整合性は満たしており、コンパイルエラーは発生しません。しかし、変数 numbers の中身は実は Integer[] ですから、Integer[] の要素として Long の値を代入しようとしています。Long は Integer のスーパータイプでもサブタイプでもありません。これは実行時に ArrayStoreException を発生させることとなります。

この振舞いは、配列からの値の取得を get()、配列への値の設定を set() メソッドだと考えて、配列を以下のような疑似コードで表現してみると理解できます。
class Number[] {
  public Number get(int i) {
    return i 番目の要素;
  }
  public void set(int i, Number v) {
    i 番目の要素 = v;
  }
}

class Integer[] extends Number[] {
  public Integer get(int i) {
    return i 番目の要素;
  }
  public void set(int i, Integer v) {
    i 番目の要素 = v;
  }
}

配列の共変性は Java 言語仕様上のバグです。
Number[]#set(int, Number) を共変引数による Integer[]#set(int, Integer) でオーバーライドしている形になりますが、引数の共変は型の安全性を崩してしまいます。そのため、ArrayStoreException などどいうランタイム例外が発生することになってしまったのです。

Java の Generics における型パラメータの共変

JDK1.5 で導入された Generics では、型パラメータの共変を許していません。例えば配列と似たセマンティクスである List を例にすると、List<Number> と List<Integer> には型の互換性はありません。配列のときは、Number[] 型の変数に Integer[] 型の値を代入することができて、問題を引き起こしていましたが、List<Number> に List<Integer> は代入できませんから、ひと安心です。でも、これだけではちょっと面白くないですよね。
実は Generics の場合は、List<? extends Number> という上限境界を設定した型パラメータを持つ変数への List<Integer> の代入が可能となっています。次のように書きます。
List<? extends Number> list = new ArrayList<Integer>();

戻り値の型が型パラメータになっている場合は、上限境界の Number 型が使われるため、get() の戻り値型は Number です。そのため、以下のようなコードはコンパイルでき、動作もします。
Number n = list.get(0);

一方、引数の型に型パラメータを使っている add() は未定義となります。そうしないとメソッドが共変的になってしまうための処置です。そのため以下のようなコードはコンパイルできません。
list.add(1);

ちょっと難しい話になってしまいましたが、お分かりいただけましたでしょうか。型の共変性、反変性はいろいろ難しく、言語設計者でも苦慮している領域です。出来てもいいはずのことが出来ない言語がけっこう多いので、こういった事情を知っておくといいと思います。