CLOVER🍀

That was when it all began.

ジェネリクスの上限/下限ワイルドカードを学ぶ

前回高階関数の話と一緒に、Java版では上限/下限ワイルドカードが登場しました。これをなかなか覚えられないので、ちょっとマジメに勉強してみようと思います。

まずは、今回のスケープゴート。

class Super {
    private String value;
    public Super(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    @Override
    public String toString() {
        return String.format("This is [%s] class, value is [%s]",this.getClass().getName(), value);
    }
}

class Basic extends Super {
    public Basic(String value) {
        super(value);
    }
}

class Sub extends Basic {
    public Sub(String value) {
        super(value);
    }
}

Super → Basic → Subの順で継承関係が成り立っています。これに対して、以下のようなコードを書いてみます。

Super s = new Super("super");
Basic b = new Basic("basic");
Sub sub = new Sub("sub");

s = b;
b = sub;
sub = b;  // コンパイルエラー

これは特に違和感ありませんよね?BasicはSuperのサブクラスなので、Super型の変数に代入できます。BasicとSubにも同じ関係が成り立ちます。また、SuperはBasicのサブクラスではないので、下位クラスから上位クラスへの代入はできません。なんてことはない、継承関係のあるクラスに対する、ごく普通の扱い方だと思います。

それでは、今度はこれらのインスタンスを格納するListを考えてみましょう。

List<Super> superList = new ArrayList<Super>();
List<Basic> basicList = new ArrayList<Basic>();
List<Sub> subList = new ArrayList<Sub>();

それぞれ、Super、Basic、Subを型パラメータに適用したListを作成しました。これを先ほどの各変数のように、代入コードを書いてみましょう。

superList = basicList;  // コンパイルエラー
basicList = subList;  // コンパイルエラー
subList = basicList;  // コンパイルエラー

なんと、全部コンパイルエラーです。

あまりこういう状態に遭遇しませんか?普通にプログラミングしている分には、こういう状況に遭遇することはないかもしれません。また、以下のようなコードは書けるのでさほど困ることはないのかもしれません。

basicList.add(s);  // コンパイルエラー
basicList.add(b);
basicList.add(sub);

Basic型で宣言したListに、Super型の変数が入れられないのはわかりますね?

ところが、パラメータ化された型を受け取るメソッドを定義したりすると、ちょっと状況が変わってきます。以下のようなメソッドを宣言してみます。

public void printList(List<Super> list) {
    for (Super s : list) {
        System.out.println(s);
    }
}

上記メソッドに先ほど各Listを引数にして呼び出そうとすると、以下のようになります。

printList(superList);
printList(basicList);  // コンパイルエラー
printList(subList);  // コンパイルエラー

メソッドや変数を宣言する時は、必要な具体性を失わない範囲で最も抽象度の高い型で宣言すると教えられていると思いますので(例えば、List list = new ArrayList();のように書く)、このことを知らずに先のprintListメソッドのような宣言をしてしまうと、そのサブクラスを格納するよう宣言されたListを受け取れないことになってしまいます。

これって、意外と困ったりするんじゃないでしょうか?ここで、Super型のサブクラスを持ったListを含めて受け取れるようにするには、以下のようなメソッドにする必要があります。

public static void printListWild(List<? extends Super> list) {
    for (Super s : list) {
        System.out.println(s);
    }
}

ここで登場している「? extends Super」が上限ワイルドカードです。意味としては、少なくともSuper型のサブクラスであることを強制する、ということになります。最上位が決まるので、上限ワイルドカードってことですね。

先の全てコンパイルエラーになってしまったListの各変数の代入関係を、パラメータに適用した型の関係と同じようにするには、以下のようなコードにする必要があります。

List<? extends Super> convariantSuperList = new ArrayList<Super>();
List<? extends Basic> convariantBasicList = new ArrayList<Basic>();
List<? extends Sub> convariantSubList = new ArrayList<Sub>();

convariantSuperList = convariantBasicList;
convariantBasicList = convariantSubList;
convariantSubList = convariantBasicList;  // コンパイルエラー

この、あるパラメータ化された型Aに対して、継承関係のある型X → Yを適用した場合にA[X] = A[Y]が成り立つ(A[Y]はA[X]のサブ型である)ことを、共変(Convariant)と言います。

ほうほう、ならば共変を使えばいいんだね!となりそうですが、事態はそう簡単ではありません。困ったことに共変を使った場合は、以下のようなコードが通らなくなります。

convariantBasicList.add(s);  // コンパイルエラー
convariantBasicList.add(b);  // コンパイルエラー
convariantBasicList.add(sub);  // コンパイルエラー

なんと、パラメータにBasic型を適用したListにBasic型やSub型の変数すら登録できません。Super型の変数を登録できないのはいいとして、Basic型やSub型まで登録できないのはなぜ?という疑問が出るかと思いますが、これは以下のようなコードを考えると解決します。

List<Sub> subList = new ArrayList<Sub>();
List<? extends Basic> basicList = subList;
basicList.add(new Basic("basic")); // あれ?元々subListはSubでパラメータ化していたんじゃあ…?(実際はコンパイルエラー)

「? extends Basic」と宣言したからといって、その中に入っているのはBasicでパラメータ化したListとは限りません。Sub型のさらにサブクラスでパラメータ化されているかもしれません。よって、上記コードがもしコンパイルエラーにならなかった場合、Sub型で宣言したListに対して、その上位クラスであるBasic型のインスタンスを登録できることになってしまい、元々の変数の型と矛盾が生じてしまいます。

んじゃあ、ワイルドカードを使った書き込みはできないの?って話になりますよね。できます!その場合は、以下のようなコードを記述します。

List<? super Super> contravariantSuperList = new ArrayList<Super>();
List<? super Basic> contravariantBasicList = new ArrayList<Basic>();
List<? super Sub> contravariantSubList = new ArrayList<Sub>();

contravariantSuperList = contravariantBasicList;  // コンパイルエラー
contravariantBasicList = contravariantSubList;  // コンパイルエラー
contravariantSubList = contravariantBasicList;

なんと、共変の時とは変数の代入関係が逆になります。この「? super Basic」のような記述を、下限ワイルドカードと呼び、少なくともBasicのスーパークラスであること、という意味になります。最も下の階層が決まるわけですね。この場合は、あるパラメータ化された型Aに対して、継承関係のある型X → Yを適用した場合にA[Y] = A[X]が成り立つ(A[X]はA[Y]のサブ型である)ようになり、これを反変(Contravariant)と言います。

なお、共変でも反変でもない(A[X]とA[Y]の間に継承関係がない)場合のことを、非変(Invariant)と言い、これがデフォルトになっています。

んで、反変とした場合には共変の時にはできなかった、以下のようなコードが書けるようになります。

contravariantBasicList.add(s);  // コンパイルエラー
contravariantBasicList.add(b);
contravariantBasicList.add(sub);

Basicで反変にしたListに対して、書き込みができるのはBasic型とSub型であり、Super型は書き込むことができません。

この辺りから、意味がわからなくなってくるんじゃないかと思います。

とりあえず、なぜ反変だと書き込みができるのかについて考えてみましょう。「? extends Basic」と書いた場合は、少なくともBasicのサブクラスであれば受け取れるようになるのですが、これに対して以下のようなコードを考えてみましょう。

List<Super> superList = new ArrayList<Super>();
List<? super Basic> basicList = superList;
basicList.add(new Basic("basic"));  // 元々SuperのListだったので、これはOK!

最初の方で、こんなコードを見せましたね?

basicList.add(s);  // コンパイルエラー
basicList.add(b);
basicList.add(sub);

Basic型で宣言されたListは、Basic型やそのサブクラスの変数を受け取ることができました。だったら、先の例であればSuper型で宣言されたListにBasic型やSub型の変数を書き込むことができても不思議はないですね?

つまり、下限が決まれば書き込みができるようになる、ということです。

これをまとめると、読み出しには上限ワイルドカードを、書き込みには下限ワイルドカードを利用するという法則が成り立ちます。これが、前回ちょっと出てきた「getとputの原則」です。

例えば、あるListの内容を別のListに追加するようなメソッドは、以下のような宣言をする必要があります。

public <T> void appendList(List<? extends T> fromList, List<? super T> toList) {
    for (T t : fromList) {
        toList.add(t);
    }
}

fromListの内容をtoListに追加するコードですね。

また、これは変換処理を請け負ったコードにも利用します。

public interface Function1<IN, OUT> {
    public OUT apply(IN in);
}

import java.util.*;

public class FunctionalCollections {
    public static <T, S> List<S> map(List<T> list, Function1<? super T, ? extends S> func) {
        List<S> newList = new ArrayList<S>();

        for (T t : list) {
            newList.add(func.apply(t));
        }

        return newList;
    }
}

前回紹介した関数インターフェースとmapメソッドですが、Functionはある型Aを受け取って別の型B(Aでもいいけど)に変換して結果を返すクラスと見ることができます。よって、ここに「getとputの原則」が成り立ちます。つまり、変換処理を行うクラス/メソッドの引数は反変に、戻り値は共変にせよ、ということです。

以下に、これを使った変換コード例を。

// SuperToBasicは、Super型を引数に取り、Basic型に変換して返すFunction1のインスタンス
// 他も同じ読み方をします
FunctionalCollections.map(superList, new SuperToBasic());
FunctionalCollections.map(basicList, new SuperToBasic());
FunctionalCollections.map(subList, new SuperToBasic());
        
FunctionalCollections.map(superList, new BasicToSub());  // コンパイルエラー
FunctionalCollections.map(basicList, new BasicToSub());
FunctionalCollections.map(subList, new BasicToSub());

FunctionalCollections.map(superList, new SubToBasic());  // コンパイルエラー
FunctionalCollections.map(basicList, new SubToBasic());  // コンパイルエラー
FunctionalCollections.map(subList, new SubToBasic());

引数のListに適用した型より低い型を引数に取る変換関数はコンパイルエラーになり、Listに適用した型よりも上位の型を引数に取る変換関数はコンパイルを通るのは理に適っているのではないでしょうか?なお、このmapメソッドの引数から「? super T」を削ると、Listに適用した型と同じ型引数を取る変換関数しかコンパイルを通らなくなります。

いやー、長いですね。これに非境界ワイルドカード(List<?>)と原型(List)を加えると、ジェネリクスのワイルドカードの基礎はとりあえずOKじゃないでしょうか?

…やっぱ難しいですよね。これでジェネリクスがOKと言えないところがまた厳しい。ジェネリックなクラスとかメソッドを宣言する時のことを、今回の内容ではま〜ったく触れてませんから♪

Javaでこの辺りを解説している書籍などがあまりないこともあり、いわゆる業務プログラマにはよくわからない領域になっているんじゃないかと思います。自分は、Scalaの勉強でこの概念を学び、そこからJavaの理解に進みました。Scalaの書籍だと、比較的この辺をちゃんと解説したものが多いんですよね。…まあ、簡単ではないですが。