【Java】Javaのジェネリクス境界(extends・super)を使いこなす

Java

こんにちは、かつコーチです。
ジェネリクスの基本を押さえた上で、実務で必ずと言っていいほど遭遇するのが? extends T? super Tの使い分けです。
今回はこの境界ワイルドカードを、PECS原則を軸に整理していきます。

extends・superが必要になる場面

不変(invariant)の壁にぶつかる

Javaのジェネリクスは、継承関係があってもList<Sub>List<Super>のサブタイプにはなりません(不変・invariant)。
これは配列との大きな違いで、次のようなコードはコンパイルエラーになります。

class Animal {}
class Dog extends Animal {}
List<Dog> dogs = new ArrayList<>();

// ❌コンパイルエラー:List<Dog>はList<Animal>のサブタイプではない
List<Animal> animals = dogs;

DogAnimalのサブクラスであっても、List<Dog>List<Animal>として扱うことはできません。
この制約自体は「もし許してしまうとanimals.add(new Cat())のような不整合が起きうるため」という型安全性のための設計ですが、実務では「読み取り専用でいいから柔軟に受け取りたい」場面が頻繁に出てきます。ここで役立つのが境界ワイルドカードです。

extendsで上限境界を指定する

? extends Tで読み取り専用のリストを柔軟に受け取る

? extends Tは「Tまたはそのサブクラス」を意味し、読み取り(取り出し)に強い境界です。

// Numberまたはそのサブクラス(Integer, Double等)のListを受け取れる
public static double sumAll(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}
List<Integer> integers = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);

System.out.println(sumAll(integers)); // 6.0
System.out.println(sumAll(doubles));  // 4.0

List<Integer>List<Double>List<? extends Number>として受け取れるようになり、先ほどの不変の壁を回避できます。

なぜextendsでは要素を追加できないのか

? extends Numberと宣言した時点で、コンパイラは「実際の型がIntegerなのかDoubleなのか分からない」状態になります。

public static void addOne(List<? extends Number> list) {
    list.add(1); // コンパイルエラー
}

このコードは次のエラーになります。

error: incompatible types: int cannot be converted to CAP#1
    list.add(1);
             ^

もしList<Double> doublesをこのメソッドに渡していた場合、そこにIntegerを追加できてしまうと型の整合性が壊れてしまいます。
そのためコンパイラは、? extends Tの型に対する追加操作を一律禁止することで安全性を担保しています(nullの追加のみ例外的に許可されます)。

superで下限境界を指定する

? super Tで書き込み専用のリストを柔軟に受け取る

? super Tは「Tまたはその親クラス」を意味し、書き込み(追加)に強い境界です。

// Dogまたはその親クラス(Animal, Object)のListに追加できる
public static void addDogs(List<? super Dog> list) {
    list.add(new Dog());
    list.add(new Dog());
}
List<Animal> animals = new ArrayList<>();
addDogs(animals); // List<Animal>はDogの親なので受け取れる

List<Object> objects = new ArrayList<>();
addDogs(objects); // List<Object>も同様に受け取れる

List<Animal>にもList<Object>にもDogを安全に追加できます。
これは、どちらのListも「Dog型のインスタンスを受け入れられる」ことが保証されているためです。

superから取り出す際はObject型になる

? super Tの型から要素を取り出すと、コンパイラはObject型としてしか扱えません。

public static void printAll(List<? super Dog> list) {
    for (Object obj : list) { // Dog型では受け取れず、Objectで受ける
        System.out.println(obj);
    }
}

実際の中身がAnimalなのかObjectなのか分からないため、共通の祖先であるObject型としてしか安全に扱えないというのが理由です。

PECS原則で使い分けを覚える

Producer-Extends, Consumer-Super

境界ワイルドカードの使い分けには、PECS(Producer-Extends, Consumer-Super、値を生産する側はextends・消費する側はsuperという意味の頭字語)という有名な原則があります。

役割使う境界具体例
データを取り出す(読む)側 = Producer? extends TsumAll(List<? extends Number> list)
データを追加する(書く)側 = Consumer? super TaddDogs(List<? super Dog> list)
読み書き両方する側ワイルドカードなし(List<T>sort(List<T> list)

私が実際にコードレビューでよく指摘する一次情報として、「読み取りしかしないメソッドの引数にList<T>(ワイルドカードなし)を使っていて、呼び出し側の型が微妙に合わずコンパイルエラーになる」というケースが頻発します。

// ❌Before:読み取り専用なのにワイルドカードを使わない
public static double sumAllStrict(List<Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}
List<Integer> integers = List.of(1, 2, 3);
sumAllStrict(integers); // コンパイルエラー:List<Integer>はList<Number>に代入できない
// ✅After:読み取り専用ならextendsで受け口を広げる
public static double sumAllFlexible(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue();
    }
    return sum;
}

「このメソッドはコレクションから値を取り出すだけか、それとも追加もするか」を最初に自問し、PECS原則に当てはめるだけで境界の選択で迷わなくなります。

まとめ

この記事のポイント

  • Javaのジェネリクスは不変(invariant)なため、List<Dog>List<Animal>として扱えない
  • ? extends Tは読み取り(Producer)向けで、追加操作はコンパイルエラーになる
  • ? super Tは書き込み(Consumer)向けで、取り出すとObject型になる
  • 読み書き両方が必要ならワイルドカードなしのList<T>を使う
  • PECS原則(Producer-Extends, Consumer-Super)を基準にすれば境界の選択で迷わない

次に読むべき記事

  • ジェネリクスの基本
  • Optionalでnullを安全に扱う

タグ: Java, 上級者向け, コレクション

タイトルとURLをコピーしました