Effective Javaを勉強します【第6章】

項目30. int定数の代わりにenumを使用する

一年での季節、太陽系の惑星、トランプの役など、固定数の定数からその値が成り立つ型を列挙型と呼ぶ。Java4以前で列挙型を表現する際には int enum パターンが用いられていたが、Java 5以降では enum 型を用いて列挙型を表現すべきである。

int enum パターン

以下は int enum パターンの例である。

public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLAE = 1;
public static final int ORANGE_BLOOD = 2;

上記は以下のような欠点を持つ。

  • 型安全性を提供しない。オレンジを期待するメソッドにアップルを渡したり、オレンジとアップルを==演算子で比較しても何のエラーにもならない。
  • 名前空間を提供しない。APPLE_等の接頭辞で名前の衝突を防ぐ手間がかかる。
  • int enum 定数を変更する度に再コンパイルの必要がある。再コンパイルされない場合、動作はするが結果がどうなるかはわからない。
  • 利便性が低い。デバッガ等で表示しても文字情報は取得することができず、定数をイテレートするなどの操作手段もない。

int 定数の代わりに String 定数が用いられる場合もあるが、文字列比較のコストやハードコートした際の誤字によるバグを考慮すると、さらに望ましくない。

enum

上記で説明した int enum 型の欠点を回避するのが enum 型である。以下はその例である。

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

enum 型は内部的にはクラスであり、クラス同様パッケージに属する。基本的には1ファイルに火取るの enum 型を定義し、型名と同じファイル名のファイルに記述する。内部クラスのように他のクラスの内部に定義することも可能。

上記Apple を定義した Apple クラスを javap コマンドで逆コンパイルすると、以下のように表示される。

Compiled from "Apple.java"
public final class Apple extends java.lang.Enum<Apple> {
  public static final Apple FUJI;
  public static final Apple PIPPIN;
  public static final Apple GRANNY_SMITH;
  public static Apple[] values();
  public static Apple valueOf(java.lang.String);
  static {};
}

これにより以下のようなことが分かる。

  • enum 宣言された型は java.lang.Enum を継承している
  • 個々の列挙定数は public static final であり、自身が宣言された型のインスタンスである
  • enum 型はアクセス可能なコンストラクタを持っていないため事実上 final であり、クライアントは enum 型のインスタンスの生成や拡張は不可能である(シングルトン)

enum は型安全性を提供している。たとえば型 Apple で引数を宣言した場合、null でないオブジェクト参照は必ず FUJI / PIPPIN / GRANNY_SMITHのいずれかの値であることが保証される。また、異なる enum 型の値を渡そうとしたり、異なる enum 型の値同士を == で比較しようとするとコンパイル時エラーになる。

また、各 enum 型は独自の名前空間を提供するため、同一名の定数が共存可能である。加えて toString メソッドにより文字列へ変換することも可能である。

enum型 へのフィールド・メソッドの追加

enum 型に任意のフィールド・メソッドを追加することにより、定数にデータを関連づけることが可能である。

例えば、太陽系の惑星について考えてみる。各惑星は、質量と半径を持ち、それらから表面重力や惑星表面上での物体の重さが計算可能である。

これらを enum で表現すると以下のようになる。

public enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS   (4.869e+24, 6.0518e6),
    EARTH   (5.976e+24, 6.37814e6),
    MARS    (6.421e+23, 3.3972e6),
    JUPITER (1.9e+27,   7.1492e7),
    SATURN  (5.688e+26, 6.0268e7),
    URANUS  (8.686e+25, 2.5559e7),
    NEPTUNE (1.024e+26, 2.4746e7);

    private final double mass;
    private final double radius;
    private final double surfaceGravity;

    public static final double G = 6.67300E-11;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        this.surfaceGravity = G * mass / (radius * radius);
    }

    public double mass() { return mass; }
    public double radius() { return radius; }
    public double surfaceGravity() { return  surfaceGravity; }
    public double surfaceWeight(double mass) { return mass * surfaceGravity; }
}

enum にデータを関連づけるためには、インスタンスフィールドを宣言し、データを受け取るコンストラクタを書き、そのフィールドにデータを保存すれば良い。enum は不変であるため、すべてのフィールドは final とすべきである。

enum の値セットを取得するにはvalues()メソッドを用いる。これにより、各惑星上での物体の重さを計算するプログラムを以下のように記述することができる。

double earthWeight = 1.0;
double mass = earthWeight / Planet.EARTH.surfaceGravity();
// 地球上で重さ1.0の物体の各惑星上での質量を計算する
for (Planet p : Planet.values())
    System.out.printf("Weight on %s is %f%n",
                      p, p.surfaceWeight(mass));

定数固有メソッド実装

Planet クラスの例では、定数ごとのフィールド値が異なってもメソッドの振る舞いは同じだったが、各定数にメソッドの振る舞いを変えたい場合がある。そのような時には定数固有メソッド実装を利用する。以下はその例。

public enum Operation {
    PLUS { double apply(double x, double y) { return x + y; } },
    MINUS { double apply(double x, double y) { return x - y; } },
    TIMES { double apply(double x, double y) { return x * y; } },
    DIVIDE { double apply(double x, double y) { return x / y; } };

    abstract double apply(double x, double y);
}

このように enum 内で抽象メソッドを宣言し、各定数ごとにオーバーライドすることで振る舞いを変化させることができる。仮に実装を忘れたとしても、コンパイル時エラーとなりそのまま動作することはない。

また、定数固有メソッドは定数固有データを同時に利用することができる。

public enum Operation {
    PLUS("+") { double apply(double x, double y) { return x + y; } },
    MINUS("-") { double apply(double x, double y) { return x - y; } },
    TIMES("*") { double apply(double x, double y) { return x * y; } },
    DIVIDE("/") { double apply(double x, double y) { return x / y; } };

    private final String symbol;
    Operation(String symbol) { this.symbol = symbol; }

    abstract double apply(double x, double y);
}

fromString メソッドの実装の検討

enum はデフォルトで、定数の名前を定数自身へ変換する valueOf(String) メソッドを持っている。(上記Operation の場合、返り値の型は Operation

もし enum 型で toString メソッドをオーバーライドする場合、それに対応する fromString メソッドの実装を検討すべきである。 fromString メソッドは、引数で渡されたカスタム文字列を定数自身へ変換する。

private static final Map<String, Operation> stringToEnum = new HashMap<>();
// toString() で定義したカスタム文字列を Map で記憶する
static {
   for (Operation op : values()) {
       stringToEnum.put(op.toString(), op);
   }
}
// toString() でカスタム文字列を定義
@Override
public String toString() {
    return symbol;
}
// toString に対応する fromString()
public static Operation fromString(String symbol) {
    return stringToEnum.get(symbol);
}

パフォーマンス

一般的に、パフォーマンスに関して enum は int 定数と比べても遜色ない。小さな差ではあるが、enum 型のロードと初期化には空間的・時間的コストが発生する。しかし資源制約の厳しいデバイス等でなければ、そのコストが顕著になることはない。

項目31. 序数の代わりにインスタンスフィールドを使用する

enumordinal() というメソッドを持っている。このメソッドは enum 型内の各 enum 定数の整数位置を返す。

public enum Ensemble {
    SOLO, DUET, TRIO, QUARTET, QUINTET,
    SEXTET, SEPTET, OCTET, NONET, DECTET;

    // Ensemble.SOLO.ordinal() であれば 1 を返す
    public int numberOfMusicians() { return ordinal() + 1; }
}

上記は動作するが、以下の理由より保守性が非常に低い。

  • 定数が並び替えられた場合、numberOfMusicians は動作しなくなる
  • 複数の定数の同じ整数値を割り当てることができない
    • 例えばダブルカルテット(4人×2)の場合、オクテット(8人)との区別ができない

そもそも ordinal メソッドは、EnumSet や EnumMap などの汎用の enum をベースとしたデータ構造の実装を手助けする手段として提供されている。したがって普通の開発者は ordinal メソッドを使うべきではない。

代わりに、下記のようにインスタンスフィールドを用いて、整数値を関連づける。これにより上記の保守性の問題はすべて解決する。

public enum Ensemble {
    SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
    SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
    NONET(9), DECTET(10), TRIPLE_QUARTET(12);

    private final int size;
    Ensemble(int size) { this.size = size; }
    public int numberOfMusicians() { return this.size; }
}

項目32. ビットフィールドの代わりにEnumSetを使用する

enum が集合で用いられる場合、従来はビットフィールドが利用されてきたが int enum パターンと同様の欠点を持つ。 代わりに EnumSet を用いることで、ビットフィールドの簡潔性とパフォーマンスを維持しつつ、欠点を解決することが可能である。

ビットフィールド

ビットフィールドとは、列挙型の各定数に異なる2の累乗を割り当てて、int enum パターンを使用する方法である。

public class Text {
    public static final int STYLE_BOLD   = 1 << 0; // 1
    public static final int STYLE_ITALIC = 1 << 1; // 2
    public static final int STYLE_UNDERLINE = 1 << 2; // 4
    public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8

    // パラメータは、0個以上のSTYLE_定数のビット
    public void applyStyles(int styles) {
        ...
    }
}

ビットフィールドとして知られている複数の定数を1つの集合にまとめるために、 以下のようにビット和操作を行う。

// 以下はtext変数がボールドかつイタリックであることを指す(引数は 0011)
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

ビットフィールドは和集合や共通集合などの操作を効率的に行うが、int enum 定数の短所を全て持っている。

EnumSet

ビットフィールドの代わりに、EnumSet が利用可能である。 EnumSet は Set インタゲースを実装しており、豊富な機能、安全性、他の Set 実装との相互運用性が提供されている。 またパフォーマンスも、ビットフィールドと比べても遜色ない。

public class Text {
    public enum Style {
        BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
    }
    // どのようなSetでも渡せるが、EnumSet が明らかに最善
    public void applyStyles(Set<Style> styles) {...}
}

各属性は以下のように設定可能である。

// 以下はtext変数がボールドかつイタリックであることを指す
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

項目33. 序数インデックスの代わりにEnumMapを使用する

下記のようなハーブを表現したクラスがあるとする。 ハーブは3種に分類可能であり(一年生植物(annual)、多年生植物(perennial)、越年性植物(biennial))、 それらを enum で表現している。

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;
    }
}

ここで、ハーブを種類ごとに Set などのコンテナに格納したいとする。 この時、種類の序数でインデックスされた配列で格納先の Set を区別してはいけない。 このような時は EnumMap を用いるのが適切である。

序数インデックスを用いた配列による分類

種類の序数でインデックスされた配列に各種ハーブを格納する場合、以下のようなコードになる。

// 種類の混在したハーブの集合
Herb[] all = new Herb[] { ... };

// 3種類のハーブに対応した3つの 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 h: all) {
    herbsByType[h.type.ordinal()].add(h);
}

この方法は以下の問題を抱える。

  • 配列はジェネリックスと互換性がないため、無検査キャストを必要とし、コンパイル時には警告が出る
  • 配列のインデックスからハーブの種類を読み取ることができない
  • 配列のアクセス時に、意図した種類と対応したインデックスが利用されるかはクライアント次第(型安全でない)

EnumSet を用いた分類

EnumSet を用いてハーブを分類する場合、以下のようなコードになる。

// 種類の混在したハーブの集合
Herb[] all = new Herb[] { ... };

// ハーブの種類をキー、値を Set としたMapを作成
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);
}

このプログラムは序数インデックスを用いる方法と比べて

  • より短く、より明瞭、より安全
  • パフォーマンスも遜色ない
  • 型安全

多次元関係

2つの enum からの対応付けを表す時も同様であり、序数を用いるよりEnumSetを用いた方が良い。 例えば水の相転移を表現したいとする。(液体(liquid)から個体(solid)は凍結(fleezing)、液体から気体(gas)は沸騰(boiling))

以下は序数を用いる例。

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 が増えると空間が無駄になる

ハーブの例と同様に、EnumMap を用いることでこれらの問題は解決する。 新たにプラズマという相を追加する場合であっても、TransitionPhase に定数を追加するだけで良い。

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, Map<Phase, Transition>>(Phase.class);
    static {
        for (Phase phase: Phase.values()) {
            map.put(phase, new EnumMap<Phase, Transition>(Phase.class));
        }
        for (Transition transition: Transition.values()) {
            map.get(transition.src).put(transition.dst, transition);
        }
    }

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

項目34. 拡張可能なenumをインタフェースで模倣する

基本の enum 型に伴うインタフェースを書いて、そのインタフェースを実装することで拡張可能な enum 型を模倣することが可能。

オペコード

オペコードは拡張可能な列挙型を使わざるをえない場面の1例。enum 型が任意のインタフェースを実装できるという事実を利用して、 オペコードに関するインタフェースを定義し、それを実装した enum を定義してやればよい。

interface Operation {
    double apply(double x, double y);
}

public enum BasicOperation implements Operation {
    PLUS("+") {
        @Override public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS("-") {
        @Override public double apply(double x, double y) {
            return x - y;
        }
    };

    private final String symbol;
    BasicOperation(String symbol) {
        this.symbol = symbol;
    }
}

// Enumは拡張不可だが、共通インタフェース Operation を利用して拡張した操作を定義している
public enum ExtendedOperation implements Operation {
    EXP("^") {
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        public double apply(double x, double y) {
            return x  % y;
        }
    };

    private final String symbol;
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
}

項目35. 命名パターンよりアノテーションを選ぶ

命名規則とは、JUnitのように「テストケースメソッドの先頭には"test"をつける」といったルールのこと。

命名規則の代わりにアノテーションを利用することには以下のようなメリットがある。

  • 命名の際の誤字などをコンパイラでチェックできる
  • パラメータを関連づけることができる
  • クラスやメソッドなど、利用箇所を限定することができる

アノテーション

JavaにはOverrideアノテーションなど、いくつかのアノテーションが用意されているが、自分でアノテーションを定義することも可能。 その際は@interfaceを用いる。

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

上記ではTestアノテーションを定義している。

また@Target(ElementType.METHOD)のように、定義時に別のアノテーションを付与することもできる(メタアノテーション)。

@Retention(RetentionPolicy.RUNTIME)Testアノテーションがメソッド宣言にのみ付与できることを示している。 @Target(ElementType.METHOD)コンパイラによってクラスファイルに記録し、実行時にVMに保持することを示している。

マーカーアノテーション

Testアノテーションのように、パラメータを付与された要素を「マーク」するためのアノテーションを指す。

public class SampleTest {
    @Test public static void m1() {}
    public static void m2() {}
    @Test public static void m3() {}
}

上記の場合Testアノテーションが付与されたm1()m2()のみがテストとして実行される。

パラメータを持つアノテーション

ある特定の例外がスローされた場合のみ成功するテストを表すアノテーションを定義することを考える。 そのためには、アノテーションに特定の例外が何か教えなければならない。

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest{
    Class<? extends Exception> value();
}

上記のように定義することで、ExceptionTestアノテーションに、特定の例外を表すパラメータを与えることが可能となる。 以下のように例外を期待するテストに利用する。

public class SampleTest2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {
        int i = 0;
        i  = i / i;
    }
}

項目36. 常にOverrideアノテーションを使用する

スーパークラスの宣言をオーバーライドしている全てのメソッド宣言に対して Override アノテーションを使用すべき。

Override アノテーションを使用することで、例えば以下のようなオーバーライドミスを コンパイラが教えてくれる。

public class Bigram {
    private final char first;
    private final char second;
    public Bigram(char first, char second) {
        ...
    }
    // 以下は間違い。正しくは public boolean equals(Object o) {}
    public boolean equals(Bigram b) {
        ...
    }
    public int hasCode() {
        ...
    }
}

Bigram クラスでは equals` メソッドのオーバーライドに失敗し、誤ってオーバーロードしている。

このようなミスは見つけづらいが 以下のように Override アノテーションを利用することでコンパイラがエラーメッセージを出力してくれる。

    @Override public boolean equals(Bigram b) {
        ...
    }

項目37. 型を定義するためにマーカーインタフェースを使用する

マーカーインタフェースとは、メソッド宣言を含まないインタフェースのことであり、 そのインタフェースを実装しているクラスが何らかの特性を持っていることを示す。

例として Serializable インタフェースが挙げられる。これはメソッドを1つも持たないが、 ObjectOutputStream に利用できるという特性を示す。

マーカーインタフェースは型を定義できるため、コンパイル時のエラーチェックが可能であるという点で マーカーアノテーションより優れている。

ただし、クラスやインタフェース以外のプログラム要素をマークしたい場合は、マーカーアノテーションを使う必要がある。 (マーカーインタフェースはクラス以外をマークすることができない。)