Java Enum の getDeclaringClass() と前方参照に纏わるしがない話

f:id:Naotsugu:20191027210540p:plain


Enum クラスの getDeclaringClass()

以下の Enum があったとします。

public enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER,
    ;
}

getClass()getDeclaringClass() は以下のような結果となります。

Season.SPRING.getClass()           // foo.Season
Season.SPRING.getDeclaringClass()  // foo.Season
Season.SPRING.getClass() == Season.SPRING.getDeclaringClass() // true

getClass()getDeclaringClass()Season となり、これはあたりまえの結果ですね。


現在の季節の、次の季節を得る next() を以下のように定義したとします。

public enum Season {
    SPRING {
        Season next() { return SUMMER; }
    },
    SUMMER {
        Season next() { return AUTUMN; }
    },
    AUTUMN {
        Season next() { return WINTER; }
    },
    WINTER {
        Season next() { return SPRING; }
    },
    ;

    abstract Season next();
}

TimeUnit などでもおなじみの書き方です(ただし現在の TimeUnit は匿名内部クラスを使わない実装に変更されています)。

この場合、 getClass()getDeclaringClass() は以下のような結果となります。

Season.SPRING.getClass()           // foo.Season$1
Season.SPRING.getDeclaringClass()  // foo.Season
Season.SPRING.getClass() == Season.SPRING.getDeclaringClass() // false

Enum 値は匿名内部クラス(anonymous inner classes)としてコンパイルされているため、Season$1 となります。まぁ、当然と言えば当然ですが。


これの何が問題かというと、例えば、以下のように Season.SPRING の文字列を得て、リソースバンドルからローカライズ名を取得するというのは良くあるケースでしょう。

season.getClass().getSimpleName() + "." + season.name()

しかし、上記は、.SPRING という文字列しか得られません。匿名内部クラスの getSimpleName() は空文字を返却するためです。匿名と言うからにはそうなのかもしれませんが、Enum における上記実装では、匿名内部クラスのようには見えにくいので、割と混乱するポイントです。

Season.SPRING というキーでリソースバンドル定義していたとすると、Season の内部実装の変更により、図らずリソースバンドルから値を取得できなくなってしまいます。


匿名クラスの getSimpleName() が空文字を返すことは意外と知られておらず、たまに問題になったりしますね。 念のため getSimpleName() の JavaDoc は以下のようになっています。

Returns the simple name of the underlying class as given in the source code. Returns an empty string if the underlying class is anonymous.

ソース・コード内で指定されたとおり、基本となるクラスの単純名を返します。基本となるクラスが匿名の場合、空の文字列を返します。


話を元に戻しましょう。Enum の getClass()getDeclaringClass() の違いについての話でした。 Enum.getDeclaringClass() の JavaDoc を見てみましょう。

この enum 定数の enum 型に対応するClassオブジェクトを返します。 e1.getDeclaringClass() == e2.getDeclaringClass() の場合だけ、2つの enum 定数 e1 と e2 は同じ enum 型の enum 定数です。 このメソッドにより返される値は、定数固有のクラス本文を持つ enum 定数について Object.getClass() メソッドで返される値とは異なる可能性があります。

実装を見た方が早いかもしれません。

public abstract class Enum<E extends Enum<E>>
        implements Comparable<E>, Serializable {

    public final Class<E> getDeclaringClass() {
        Class<?> clazz = getClass();
        Class<?> zuper = clazz.getSuperclass();
        return (zuper == Enum.class) ? (Class<E>)clazz : (Class<E>)zuper;
    }
}

Enum 定数の親クラスが Enum.class の場合は、getClass() と同じ。そうでなければ親クラスを返しています。

Enum 定数値のクラスを取得するという、多くの場合の(一般的な)意図に即するには、Enum のクラスは getDeclaringClass() で取得するのが望ましいでしょう。

逆に言えば、Enum については getClass() を使うべきではない と覚えておいた方が良いです。


Enum 定数の前方参照定義

先の例では、次の季節を得るために、以下の実装を考えました。

public enum Season {
    SPRING {
        Season next() { return SUMMER; }
    },
    SUMMER {
        Season next() { return AUTUMN; }
    },
    AUTUMN {
        Season next() { return WINTER; }
    },
    WINTER {
        Season next() { return SPRING; }
    },
    ;

    abstract Season next();
}

匿名内部クラスを利用しなければ、前述の getClass() の問題は発生しません。

ですので、以下のように変更すればどうでしょうか?

public enum Season {
    SPRING(SUMMER),
    SUMMER(AUTUMN),
    AUTUMN(WINTER),
    WINTER(SPRING),
    ;

    private final Season next;

    Season(Season next) {
        this.next = next;
    }
}

しかし、これは前方参照エラーとなりコンパイルできません。 SPRING の定義には(この時点で未定義の)SUMMER が必要となるためです。

ですので匿名内部クラス定義に流れる場合が多いのですが、以下の実装の方が望ましいでしょう。

public enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER,
    ;

    public Season next() {
        return switch(this) {
            case SPRING -> SUMMER;
            case SUMMER -> AUTUMN;
            case AUTUMN -> WINTER;
            case WINTER -> SPRING;
        };
    }
}

Java14 からの switch 式を使っています。Enum 値を追加した場合でも、コンパイラが switch式の網羅性を検証し、漏れがあった場合にはコンパイルエラーになるため安心です。

Enum を匿名内部クラスで定義するのは一見スマートに見えますが、意図の集約という観点からも、上記実装の方がより良いと言えるのではないでしょうか。


クラスの同一性について

ここからは全くのおまけです。

先に示した例では、以下のようにクラスを == で比較しました。

Season.SPRING.getClass() == Season.SPRING.getDeclaringClass()

equals() を使えば以下のようになります。

Season.SPRING.getClass().equals(Season.SPRING.getDeclaringClass())

どちらを使うべきでしょうか?

Class は 以下のように final 宣言されており、 equals() の実装は Object.equals() で行われます。

public final class Class<T> implements java.io.Serializable,
                              GenericDeclaration,
                              Type,
                              AnnotatedElement {
}
public class Object {
    public boolean equals(Object obj) {
        return (this == obj);
    }
}

Object.equals() は、== で同一性の比較をしているため、クラスの比較は、== でも equals() でも同じになります。

クラスオブジェクトは、同一のクラスローダー上では同じ唯一のインスタンスとなりますが、異なるクラスローダー上では異なるインスタンスとなります。

クラスローダーが分かれた場合には、それらは区別され、同一とはみなされません。 同じクラスの異なるバージョンかもしれませんし、それらがたまたま同じものであっても互換性はありません。

ですので、Class インスタンスは、== で同一性を判定しておけば通常は問題になりません。