【Effective Java】項目33:序数インデックスの代わりに EnumMap を使用する

序数インデックス

配列のインデックスとして Enum の ordinal() を使ってはいけません。

例えばハーブの種類(一年生(Annual)、多年生(Perennial)、越年生(Biennial))ごとに、ハーブをまとめたいと仮定します。 このような場合、配列と Enum.ordninal() を利用して以下のようなコードを書いてはいけません。

public class Herb {
    enum Type {
        Annual, Perennial, Biennial
    }

    private String name;
    private String type;

    public Herb(String name, String type) {
        this.name = name;
        this.type = type;
    }
}
// 様々なハーブがある
Herb[] all = new Herb[] { ... };

// ハーブの種類ごとに Set を用意
Set<Herb>[] herbsByType =
    (Set<Herb>[]) new Set[Herb.Type.values().length];
for (int i = 0; i < herbsByType.length; i++) {
    herbsByType[i] = new HashSet<Herb>();
}

// ハーブを種類ごとに分類
for (Herb herb: all) {
    herbsByType[h.type.ordinal()].add(herb);
}

まず、配列とジェネリックスには互換性がありません。 ジェネリックスの配列を使おうとすると無検査キャストの警告が発生します。

加えて Enum の序数(int 型)でインデックスされているため、型安全性が提供されません。 また、配列の要素数以上の int 値を与えると ArrayIndexOutOfBoundsException が発生してしまいます。

EnumMap を使う

上記の例では配列を用いましたが、本来、ここでは「ハーブの種類の enum からハーブ集合へのマップ」が正しいデータ構造です。

そのため、配列ではなくマップを利用するべきです。 特に、Java には Enum をキーとするように設計された非常に高速な Map 実装があります。 それが EnumMap です。

EnumMap を用いて書き直した実装は以下のようになります。

// 様々なハーブがある
Herb[] all = new Herb[] { ... };

// ハーブの種類ごとに Set を用意
Map<Herb.Type, Set<Herb>> map = new EnumMap<>(Herb.Type.class);
for (Herb.Type type: Herb.Type.values() {
    map.put(herb.type, new HashSet<Herb>());
}

// ハーブを種類ごとに分類
for (Herb herb: all) {
    map.get(herb.type).add(all);
}

このプログラムはより短く、明瞭で、型安全です。 EnumMap は内部的には配列を用いて実装されているため、配列バージョンと比較してもパフォーマンス上の遜色はありません。

EnumMap のコンストラクタはキーの Enum の Class オブジェクトが必要です。 これは項目29で説明した境界型型トークンであり、実行時のジェネリクス型情報を提供します。

多次元関係

表現しようとしている関係が Enum の定数による多次元構造であるなら二重 EnumMap を利用するべきです。

物体の状態(気体、液体、固体)と状態変化(凍結、沸騰、など)を関連付ける処理を考えてみます。 物体の状態と状態変化をそれぞれ Phase, Transition という Enum に対応づけます。

public enum Phase {
    SOLID, LIQUID, GAS;
}

public enum Transition {
    MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
}

これを用いて、たとえば「液体」から「気体」になる状態変化を求めたいとします。 この場合に以下のような二重配列を使って実装してはいけません。

// 遷移配列
Transition[][] TRANSITIONS = {
            { null,    MELT,     SUBLIME},
            { FREEZE,  null,     BOIL},
            { DEPOSIT, CONDENSE, SUBLIME}
};

// 遷移取得メソッド
public static Transition from(Phase src, Phase dst) {
    return TRANSITIONS[src.ordinal()][dst.ordinal()];
}

これには以下のような欠点があります。

  • 定数を追加・削除した際に変更を忘れやすい
  • null のエントリーが増えると空間が無駄になる
  • 表の大きさが Phase の 2 乗になる

これらは、以下の様に二重の EnumMap として管理するべきです。

public enum Transition {
    MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
    BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
    SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);

    private final Phase src;
    private final Phase dst;

    public Transition(Phase src, Phase dst) {
        this.src = src;
        this.dst = dst;
    }

        // 二重 Map
    private static final Map<Phase, Map<Phase, Transition>> map =
        new EnumMap<>(Phase.class);
    static {
        for (Phase phase: Phase.values()) {
            map.put(phase, new EnumMap<>(Phase.class));
        }
        for (Transition transition: Transition.values()) {
            map.get(phase).put(transition.dst, transition);
        }
    }

    public static Transition from(Phase src, Phase dst) {
        return map.get(src).get(dst);
    }
}

感想

EnumMap はコンストラクタEnum のクラス情報(例えば Herb.Type.class)を与える必要がある。 HashMap などではこういうことをしないので、なぜだろう?と気になったので EnumMap のソースを見てみた。

get や put の際に与えられたキーが、宣言されているキーの Enum かどうかを確認するため、このクラス情報を利用しているようだ。 もし、渡されたキーが宣言した Enum でなければ、 get では null が返るようになっているし、put の場合は ClassCastException が発生するようになっている。

なぜ EnumMap はこのような仕組みを使っているのか? HashMap におけるキーの同一性チェックは以下のように行う。

  • キー同士の hashCode() の値が同じかどうか
  • キー同士の equals() が true かどうか

しかし、EnumMap ではキーの同一性に ordinal() を使っている。 ordinal() だけを見てしまうと、他の Enum 型の定数がきた場合に一致してしまうことがある。 なぜなら、ordinal() は Enum 内における定数順序を示すインデックスであって、Enum ごとの定数を識別できないからだ。

そこで、コンストラクト時に型情報を与えて、実行時に型をチェックしてから ordinal() を使うようにしている。

ジェネリックスを使っていれば、コンパイル時に上記のケースははじけるはず、と思ったのだが get は Object を受けているので弾けない。 当然なのだが、きちんと考えられているなぁと思った。