Javaジェネリクス:基本構文、境界制約、および型消去の内部動作

ジェネリッククラスとメソッドの定義方法

Javaにおいて再利用可能なデータ構造や操作を定義する際、具体的な型を決めずにコードを記述するための機構がジェネリクスです。型パラメータはクラス宣言部またはメソッド宣言部の先頭に配置し、型推論によってコンパイル時に検証が行われます。

単一の型パラメータを受けるクラスの場合、フィールドとアクセサメソッドのパラメータとして同じ識別子を使用します。以下は双方向の値を保持する例です。

public class DataHolder<T> {
    private T leftSide;
    private T rightSide;

    public DataHolder(T initialLeft, T initialRight) {
        this.leftSide = initialLeft;
        this.rightSide = initialRight;
    }

    public T retrieveLeft() {
        return leftSide;
    }

    public void updateRight(T targetValue) {
        this.rightSide = targetValue;
    }
}

両側の要素に異なる型を許容する場合、カンマ区切りで複数の型パラメータを宣言します。

public class DualStore<K, V> {
    private K keyComponent;
    private V valueComponent;

    public DualStore(K k, V v) {
        this.keyComponent = k;
        this.valueComponent = v;
    }

    public K fetchKey() {
        return keyComponent;
    }

    public void replaceValue(V newValue) {
        this.valueComponent = newValue;
    }
}

メソッド単位で型パラメータを定義することも可能です。戻り値の型や引数の型に対して制約を与えたい場合に利用されます。静的メソッドでも同様に機能します。

public static <E> E getMidpoint(E[] source) {
    if (source == null || source.length == 0) {
        throw new IllegalArgumentException("配列が空です");
    }
    int centerIndex = source.length / 2;
    return source[centerIndex];
}

型パラメータに対する制約条件(Bounds)

ジェネリックなコード内でメソッド呼び出しや演算を行う場合、対象の型がその操作をサポートしていることをコンパイラに保証する必要があります。このために使用するのが境界制約です。

通常のクラス継承ではextends、インターフェース実装ではimplementsを使用しますが、ジェネリクスの境界制約では一律extendsキーワードが採用されます。これは「サブタイプである」という意味合いになります。

public static <E extends Comparable<? super E>> E findLowest(E[] data) {
    if (data == null || data.length == 0) {
        return null;
    }
    
    E currentMin = data[0];
    for (int i = 1; i < data.length; i++) {
        if (currentMin.compareTo(data[i]) > 0) {
            currentMin = data[i];
        }
    }
    return currentMin;
}

上記の例では、compareToメソッドの存在を確実にするためにComparableインターフェースを境界として指定しています。これを指定しない場合、コンパイラは引数に該当メソッドが含まれるか判断できずエラーとなります。

複数の条件を満たす必要がある場合は&で連結します。

public class Span<T extends Comparable<? super T> & Serializable> {
    private T rangeStart;
    private T rangeEnd;

    public Span(T a, T b) {
        if (a.compareTo(b) <= 0) {
            this.rangeStart = a;
            this.rangeEnd = b;
        } else {
            this.rangeStart = b;
            this.rangeEnd = a;
        }
    }

    public T getMinLimit() {
        return rangeStart;
    }
}

コンパイル時の処理と型消去(Type Erasure)の影響

Javaの仮想マシン(JVM)は実行時データ内にジェネリック型の情報を持たず、全てのリフレクションやインスタンス生成は非パラメタライズされた形式で行われます。コンパイル過程で発生するこの現象を型消去と呼びます。

左辺に宣言された最初の境界型が、対応する型パラメータの代わりに埋め込まれます。以下のコードがコンパイルされるときを考えます。

// ソースコード
public class Span<T extends Comparable<? super T> & Serializable> { ... }

型消去後の内部構造は以下のようになります。境界リストの先頭にあるComparableがフィールドの実際の型に置き換えられます。

// 型消去後の擬似バイトコード相当
public class Span implements Serializable {
    private Comparable rangeStart;
    private Comparable rangeEnd;

    public Span(Comparable a, Comparable b) {
        if (a.compareTo(b) <= 0) {
            this.rangeStart = a;
            this.rangeEnd = b;
        } else {
            this.rangeStart = b;
            this.rangeEnd = a;
        }
    }
}

境界の順序を入れ替えると、埋め込まれる基本型も変化します。例えばT extends Serializable & Comparableとした場合、Serializableがフィールド型になり、compareToの呼び出し箇所には明示的なキャストが挿入される必要があります。

// 順序変更後の型消去結果
public class Span implements Serializable {
    private Serializable rangeStart;
    private Serializable rangeEnd;

    public Span(Serializable a, Serializable b) {
        // キャストが自動挿入される
        if (((Comparable)a).compareTo(b) <= 0) {
            this.rangeStart = a;
            this.rangeEnd = b;
        } else {
            this.rangeStart = b;
            this.rangeEnd = a;
        }
    }
}

不要なランタイムキャストの発生を防ぎ、生成されるコードの効率を最大化するため、必ずスーパークラスを最初に配置し、インターフェースを境界リストの末尾に続けることが推奨されます。

タグ: java-generics type-erasure generics-bounds jvm-architecture java-typing-system

8月11日 07:58 投稿