NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    Javaのenumの優れている点を布教したい

    はじめに

    こんにちは、草野です。
    最近、Java以外の言語について学んでいますが、ほかの言語と比較することでJavaの優れている点が際立つと感じています。
    そのなかでも、Javaのenum型は非常に使い勝手が良いと感じたため、ここで一度整理し、その魅力を広めたいと思います。

    Javaのenum(列挙型)とは

    まずはJavaのenumについて説明します。
    enumとは、簡単に言うと、名前のついた定数の集まりを定義できる型のことです。

    従来のenum

    Javaの定数といえば、int定数を用いることが多いかと思います。

    public class Direction {
        public static final int NORTH = 0;
        public static final int EAST = 1;
        public static final int WEST = 2;
        public static final int SOUTH = 3;
    }
    

    列挙型をこのように表す手法を、int Enumパターン と呼びます。
    ですが、int Enumパターン にはいくつかの問題があります。
    その1つとして、型安全ではないことが挙げられます。
    例えば、次のように int Enumパターン が引数として指定されることを想定しているメソッドがあるとします。

    public class DirectionMessageProvider {
        public String getMessage(int direction) {
            return switch (direction) {
                case Direction2.NORTH -> "北に進みます";
                case Direction2.EAST -> "東に進みます";
                case Direction2.WEST -> "西に進みます";
                case Direction2.SOUTH -> "南に進みます";
                default -> throw new IllegalArgumentException("不正な値が指定されました");
            };
        }
    }
    

    ここで、getMessageの引数にDirection.NORTHや Directionで定義されている0を指定すれば問題なく動作しますが、100を指定するとどうなるでしょうか?

    100はDirectionで定義されていないため、default句を通り例外を発生させてしまいます。
    今回はdefault句で例外を発生させているのでバグに気づくことができますが、例外を握りつぶしていた場合はバグ発見の遅延につながります。
    以上のことから、int Enumパターンは型安全ではないと言えます。

    enum型の導入

    こういった int Enumパターン の問題を解消するため、Java SE 5.0から特殊目的の参照型であるenum型が導入されました。
    このenum型を用いて先ほどのDirectionクラスを修正すると、次のようにシンプルに記述できます。

    public enum Direction {
        NORTH,
        EAST,
        WEST,
        SOUTH
    }
    

    このenum型を引数としてgetMessage関数を修正してみると、次のようになります。

    public class DirectionMessageProvider {
        public String getMessage(Direction direction) {
            return switch (direction) {
                case NORTH -> "北に進みます";
                case EAST -> "東に進みます";
                case WEST -> "西に進みます";
                case SOUTH -> "南に進みます";
                default -> throw new IllegalArgumentException("不正な値が指定されました");
            };
        }
    }
    

    このメソッドの引数にはDirection型の値以外は指定できず、誤ってint型などを指定するとコンパイルエラーになるため、すぐにミスに気づくことができます。

        public static void main(String[] args) {
            var provider = new DirectionMessageProvider();
            provider.getMessage(Direction.NORTH);
            provider.getMessage(0); // コンパイルエラー!
        }
    

    誤った型を指定するとコンパイルエラーになるということは、型安全であるといえます。

    ところで、このswitch文の書き方に少し違和感を覚えませんか?
    あたかもプリミティブ型の比較のように見えますが、なぜでしょうか。
    その理由はenum型のインスタンス生成が特殊であるためです。


    enum型のインスタンス生成

    enum型のインスタンス生成はほかの一般的なクラスとは違い、少し特殊です。

    enum型は一般的なクラスのように1つの列挙型としてインスタンスが生成されるわけではなく、各列挙子ごとにインスタンス化されます。

    また、enum型のクラスはコンパイラによってコンストラクタがprivateであることを強制されます。
    コンストラクタがprivateということは、外部からインスタンスを生成することが不可能であることを保証します。
    加えて、実行時にJVMがただ一度だけインスタンス生成し、それ以外にインスタンス生成されることはありません。

    つまり、enum型の列挙子はシングルトンとなります。

    例として各方角に座標データを持たせたうえで、コンストラクタを記載してみます。

    public enum Direction {
        // インスタンス
        NORTH(0, 1),
        EAST(1, 0),
        WEST(-1, 0),
        SOUTH(0, -1);
    
        // フィールド
        private final int deltax;
        private final int deltay;
    
        // コンストラクタ
        private Direction(int deltax, int deltay) {
            this.deltax = deltax;
            this.deltay = deltay;
        }
    }
    

    このとき、各列挙子ごと、例えばNORTHEASTという単位ごとに一意のインスタンスが生成されています。

    ここで、先ほどのswitch文に戻ってみます。

    public class DirectionMessageProvider {
        public String getMessage(Direction direction) {
            return switch (direction) {
                case NORTH -> "北に進みます";
                case EAST -> "東に進みます";
                case WEST -> "西に進みます";
                case SOUTH -> "南に進みます";
                default -> throw new IllegalArgumentException("不正な値が指定されました");
            };
        }
    }
    

    enum型のインスタンス生成方法を踏まえて見てみると、プリミティブ型の比較のような文法になっている理由がわかるのではないでしょうか。
    enum型はシングルトンであるがゆえに、インスタンス比較でも自ずと同値になります。
    つまり、明示的に実体同士の比較をせずとも良いということですね。
    enum型同士の比較にequalsメソッドではなく==が使用できるのも同じ理由となっています。


    列挙子にメソッドを持たせる

    enum型には、データ以外にも各列挙子固有の振る舞いを持たせることもできます。
    enum型にメソッドを持たせる例をいくつか見てみましょう。

    すべての列挙子共通のメソッドを実装する

    まずは、先ほどのDirectionのすべての列挙子に対して、"( 0 , 1 )"のようなString型の座標を返却するメソッドを実装してみます。

    public enum Direction {
        NORTH(0, 1),
        EAST(1, 0),
        WEST(-1, 0),
        SOUTH(0, -1);
    
        private final int deltax;
        private final int deltay;
    
        private Direction(int deltax, int deltay) {
            this.deltax = deltax;
            this.deltay = deltay;
        }
    
        // すべての列挙子が共通の振る舞いを行うメソッド
        public String getStringCoordinate() {
            return "( %s , %s )".formatted(deltax, deltay);
        }
    }
    

    一般的なクラスと同じようにメソッドを定義することで、共通メソッドの実装ができます。

    各列挙子ごとに固有の振る舞いを持つメソッドを実装する

    では、各列挙子ごとに異なった振る舞いをしてもらいたい場合はどのような実装にすればよいのでしょうか?
    例として、Directionの各列挙子に対し座標を与えることで、新しい座標を計算するメソッドを実装してみます。

    public enum Direction {
        NORTH(0, 1) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x, y + 1 };
            }
        },
        EAST(1, 0) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x + 1, y };
            }
        },
        WEST(-1, 0) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x - 1, y };
            }
        },
        SOUTH(0, -1) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x, y - 1 };
            }
        };
    
        private final int deltax;
        private final int deltay;
    
        // 各列挙子に実装してもらいたい抽象メソッド
        public abstract int[] calcPosition(int x, int y);
    
        private Direction(int deltax, int deltay) {
            this.deltax = deltax;
            this.deltay = deltay;
        }
    
        // すべての列挙子が共通の振る舞いを行うメソッド
        public String getStringCoordinate() {
            return "( %s , %s )".formatted(deltax, deltay);
        }
    }
    

    このようにenum型において抽象メソッドを宣言することで、列挙子に対して固有メソッドの実装を強制することができます。

    enumをインタフェースで拡張してみる

    enum型において、interfaceの実装をすることも可能です。
    例えば、先ほどの新しい座標を計算するcalcPositionメソッドを抜き出して、interfaceに定義してみます。

    public interface DirectionInterface {
        int[] calcPosition(int x, int y);
    }
    

    こうすることで、Directionクラスから抽象メソッドの定義を分離させ、具象メソッドの実装に集中できます。

    public enum Direction implements DirectionInterface {
        NORTH(0, 1) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x, y + 1 };
            }
        },
        EAST(1, 0) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x + 1, y };
            }
        },
        WEST(-1, 0) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x - 1, y };
            }
        },
        SOUTH(0, -1) {
            public int[] calcPosition(int x, int y) {
                return new int[] { x, y - 1 };
            }
        };
    
        private final int deltax;
        private final int deltay;
    
        private Direction(int deltax, int deltay) {
            this.deltax = deltax;
            this.deltay = deltay;
        }
    
        public String getStringCoordinate() {
            return "( %s , %s )".formatted(deltax, deltay);
        }
    }
    

    また、他のenum型にも具象メソッドの実装を強制できるため、共通ロジックではinterfaceを使用すると拡張性が高くなります。

    例として、interfaceの実装有無のbooleanを返却するメソッドを作成してみます。
    まず、動作確認のためにinterfaceを実装していないenum型を作成します。

    public enum EmptyEnum {
        EMPTY;
    }
    

    次に、interfaceの実装クラスであるかの判定を行うメソッドhasCalcPositionMethodを作成します。

    public class PositionCalculator {
        public  <E extends Enum<E>> boolean hasCalcPositionMethod(E enumInstance) {
            return enumInstance instanceof DirectionInterface;
        }
    
        public static void main(String[] args) {
            var calclator = new PositionCalculator();
            System.out.println(calclator.hasCalcPositionMethod(Direction.NORTH)); // true
            System.out.println(calclator.hasCalcPositionMethod(EmptyEnum.EMPTY)); // false
            System.out.println(calclator.hasCalcPositionMethod(new EmptyImpl())); // コンパイルエラー!
        }
    }
    

    このメソッドでは、instanceof演算子により型チェックが行われ、
    DirectionInterfaceが実装されている場合はtrueを、実装されていない場合はfalseを返却します。

    また、実はenum型のクラスは暗黙的にjava.lang.Enumクラスを継承しています。
    そのため、ジェネリクスを利用することで、Enumクラスを継承している引数に限定することができます。
    具体的には、<E extends Enum<E>>で引数を絞り込んでおり、enum型以外の引数に対してはコンパイルエラーを発生するようにしています。

    このように、interfaceを上手く活用することで、共通化など拡張性が高い設計にすることができます!

    おわりに

    Javaのenum型はただの列挙型ではなく、型安全を保証してくれたり、拡張性の高い型であることがおわかりいただけたでしょうか?
    私は最近までenum型にinterfaceを実装できることを知らなかったです。
    enum型は特別な型ですが、クラスの一種であることに変わりはないということですね。

    参考書籍


    Javaプログラマにおいての必読書と謳うだけあり、Javaの言語仕様について網羅的に記載されています。
    基礎的なコードの書き方からデザインパターンまで幅広い知識が身に付くおすすめの書籍です!(私もまだ読んでいる途中ですが……)
    また、Oracle公式ドキュメント内でも紹介されている書籍なので非常に信頼できる書籍でもあります。

    執筆者:草野理沙
    Webアプリケーションエンジニア
    緑が好き💚