🦭

Java の enum を使いこなせるあなたに sealed interface

2024/02/18に公開

はじめに

Java の enum は大変便利で非常多くのシーンで活用されています。例えば区分を表すようなオブジェクトを表現したい際にもよく使われていますね。

Java 14 で正式機能となった switch式にて網羅性検査が行えるようになり、それまで以前ではどうしても抽象メソッド等を活用する必要があった処理についても、switch式を利用する事で簡潔に表現することができるようになりました。

また、Java 17 で正式機能となった sealed classes/interfaces と Java 21 で正式機能になった Record Patterns によって、これまで必要だった区分値のような enum を必ずしも定義しなくて良い場合も出てきました。

この記事では、今まで enum を使っていたコードがこれらの機能によってどのように変わるのかを紹介し、盲目的に enum を定義するのではなく、いつ enum を定義すればよいかを判断できるようになるための材料を提供したいと思います。

使用する Java のバージョンは Java 21 です。

最初のコード

まず例として User を表現するクラスを考えます。

ユーザーの種別として RegularUserAdminUser の2種が存在しているとします。

package my.cool.app.domain;

import java.util.List;

public record User(
    UserId id,
    UserName name,
    UserType type,
    List<Permission> permissions
) {
    public enum UserType {
        REGULAR,
        ADMIN;
    }
    ...中略
}

この RegularUserAdminUser を表現するために UserType という enum を定義し、User クラスはそれを属性として保持しています。

ここで User クラスに権限チェックを行うためのメソッドを追加するとしましょう。シグネチャとしては以下のイメージです。

    public boolean canOperate(Operation operation)

RegularUser であれば保持しているパーミッションの中に操作に該当するものがあれば true を返し、AdminUser であればどんな操作でも常に true を返す仕様です。

Java 14以前ではここで enum の抽象メソッドが活躍していました。

    public enum UserType {
        REGULAR {
            @Override
            protected boolean canOperate(User user, Operation operation) {
                return user.permissions()
                    .stream()
                    .anyMatch(p -> p.isAllowed(operation));
            }
        },
        ADMIN {
            @Override
            protected boolean canOperate(User user, Operation operation) {
                // Admin ユーザは全ての操作を行える
                return true;
            }
        };

        protected abstract boolean canOperate(User user, Operation operation);
    }

UserType enum に抽象メソッドを定義し、REGULARADMIN それぞれにて期待する処理を定義します。

これにより User クラス側では単純に type に委譲するだけで済ませることができます。

public record User(
    UserId id,
    UserName name,
    UserType type,
    List<Permission> permissions
) {

    public enum UserType {
        (中略)
    }

    public boolean canOperate(Operation operation) {
        return type.canOperate(this, operation);
    }

}

Switch式による書き換え

このコードは Java 14 以降では switch式を使って書き換える事ができます。

public record User(
    UserId id,
    UserName name,
    UserType type,
    List<Permission> permissions
) {

    public enum UserType {
        REGULAR,
        ADMIN;
    }

    public boolean canOperate(Operation operation) {
        return switch (type) {
            case REGULAR -> permissions.stream().anyMatch(p -> p.isAllowed(operation));
            case ADMIN   -> true; // Admin ユーザは全ての操作を行える
        };
    }

}

とても簡潔になりましたね。

Java 14 以前でも switch文や if文によって enum の分岐を書くことができましたが、その場合は網羅性検査が行えないため、後の改修で UserTypePowerUser が追加されたとしても switch文や if文で分岐しているコードを修正する必要がある事に気付けません。

それに対し、enum の抽象メソッドを使ったコードや switch式を使ったコードでは、UserType に新たな要素が増えた場合、適切に処理を追加しない限りコンパイルエラーになります。

ここで注意しておくべき事はこういった 自分たちで定義した enum に対する switch 式では、default ケースを使わない事 です。

    public boolean canOperate(Operation operation) {
        return switch (type) {
            case REGULAR -> permissions.stream().anyMatch(p -> p.isAllowed(operation));
            case ADMIN   -> true; // Admin ユーザは全ての操作を行える

            // 理由がない限り強く非推奨
            default      -> throw new IllegalStateException("Unexpected value: " + type);
        };
    }

なぜならば、default ケースを定義してしまうと上記のような UserType に新たな要素を増やした際にこの switch式を修正しなければいけない事に気付けないからです。

こんな単純な例では「そんな事やるわけないよ~」と思うかもしれませんが、要素数の多い enum のうち特定要素だけイレギュラー処理したい、みたいなケースでうっかりやらかすのを良く見かけます。

public enum Hoge {
    FOO,
    BAR,
    BAZ,
    QUX,
    QUUX;

    public int calculate(int arg) {
        return swtch (this) {
            case FOO -> arg * 2; // FOO の時は2倍する
            default  -> arg;
        }
    }
}

そして FOO 同様にイレギュラー処理が必要な新しい要素が足された時に、修正が漏れて事故になるケースが実際に起きたりしています。

多少面倒でもなるべく全部列挙しておく事を個人的には推奨しています。

        return swtch (this) {
            case FOO                 -> arg * 2; // Fooの時は2倍する
            case BAR, BAZ, QUX, QUUX -> arg;
        }

Sealed classes/interfaces による書き換え

ちょっと話がそれましたが User のコードに戻りましょう。以下 switch 式を使ったコードの再掲です。

package my.cool.app.domain;

import java.util.List;

public record User(
    UserId id,
    UserName name,
    UserType type,
    List<Permission> permissions
) {

    public enum UserType {
        REGULAR,
        ADMIN;
    }

    public boolean canOperate(Operation operation) {
        return switch (type) {
            case REGULAR -> permissions.stream().anyMatch(p -> p.isAllowed(operation));
            case ADMIN   -> true; // Admin ユーザは全ての操作を行える
        };
    }

}

この User クラスはモデルとして気になる点がありますね。それと言うのも List<Permission> permissions という属性を public に公開している所です。

RegularUser の場合は適切な値が入っているとして、AdminUser の場合は何が入っているべきでしょうか?

全ての Permission を保持している方が意味的には適切そうですが、全く利用しないのに持たせるのもパフォーマンスやその他の面で気になります。使用しない事が判ってる値を生成するためのコードが増えるのもメンテナンス観点からも避けたいです。

では空リストを持たせるべきでしょうか? もし User クラスの定義とは別のクラスで、User#canOperate メソッドの存在を知らない開発メンバーが以下のようなコードを書いてしまったらどうでしょう?

  public class DocumentService {
  ...
+     public Document editTitle(DocumentId id, Title title) {
+         var doc = documentRepository.findById(id);
+         var op = Operation.edit(doc);
+         var canEdit = loggedInUser.permissions()
+             .stream().anyMatch(p -> p.isAllowed(op));
+         if (!canEdit) throw new UnauthorizedException(id, op, loggedInUser);
+         return doc.withTitme(title);
+     }
  ...

Pull Request の diff として上記の差分だけ見た場合、レビュアーが User#canOperate の存在を知っていないと問題がある事に気付くことが難しくなります。

開発生産性を上げるためには、変更のコード差分だけを見てその変更が適切かどうか判断できるようにするという事も非常に大切な要素です。

そもそもとして User クラスの List<Permission> permissions 属性が公開されている事が問題なんだ情報隠蔽はどうしたんだ、とお考えの方も居るかと思います。全く持って適切な問題提起だと
言えるでしょう。

しかしレイヤードアーキテクチャを採用している場合、単純に List<Permission> permissions を private にする訳にはいかない場合があります。

User クラスの定義とは異なるレイヤで永続化を行うための UserRepository を実装する必要がある場合、そこでは User の全ての属性にアクセスできないと適切な永続化が行えません。

package my.cool.app.infra; // User クラスの定義とは別 package

public class UserRepository {
...
    public boolean store(user user) {
        var userTableSucceeded = dbClient.execute(
          "UPDATE user SET name = ? WHERE id = ?", user.name(), user.id()
        );
        var permTableSucceeded = switch (user.type()) {
            case REGULAR -> ...// permissions() にアクセスできる必要がある
            case ADMIN   -> true
        }
        return userTableSucceeded && permTableSucceeded;
    }
...
}

ではやはり全ての Permission を持たせるべきでしょうか?

こういった時に sealed classes/interface を使って User クラスの定義を変える事で問題を解決できる場合があります。実際に User クラスを書き換えてみましょう。

package my.cool.app.domain;

import java.util.List;

public sealed interface User {

    record Regular(UserId id, UserName name, List<Permission> permissions)
        implements User {}
    record Admin(UserId id, UserName name) implements User {}

    UserId id();
    UserName name();

    default boolean canOperate(Operation operation) {
        return switch (this) {
            case Regular(_, _, var permissions) -> 
                    permissions.stream().anyMatch(p -> p.isAllowed(operation));
            case Admin _ -> true; // Admin ユーザは全ての操作を行える
        };
    }

}

RegularUserAdminUser かの種別を表現するために UserType enum を利用する事をやめて、 User 自体の構造を RegularAdmin で表現しています。

こうする事で Admin が不自然な List<Permission> permissions を持つ事が無くなりました。不用意に permissions を使用してしまうコーディングミスも抑制できそうです。

この定義であれば別レイヤの永続化層でも問題なく User の中身にアクセスすることができます。

package my.cool.app.infra; // User クラスの定義とは別 package

public class UserRepository {
...
    public boolean store(user user) {
        var userTableSucceeded = dbClient.execute(
          "UPDATE user SET name = ? WHERE id = ?", user.name(), user.id()
        );
        var permTableSucceeded = switch (user) {
            case Regular(_, _, var permissions) -> ...// permmisions が扱える
            case Admin _                        -> true
        }
        return userTableSucceeded && permTableSucceeded;
    }
...
}

今回のような RegularUserAdminUser で属性の差分が少なく同一テーブルに保存している場合はもちろん、例えば「個人会員」と「法人会員」で保持している属性が根本的に異なり、永続化するテーブル自体も異なるような Account クラスを定義したい、といった時においては更に上記で提示したような sealed interface による定義は非常に効果的に働きます。

まとめ

Java の enum は網羅的な分岐を実現するために非常によく活用されています。

Java 14 で正式機能となった switch式を活用することで、これまで enum の抽象メソッドで表現していた処理をより簡潔に記述できるようになりました。

また、Java 17 で正式機能となった sealed classes/interfaces を利用する事で、そもそも区分値自体を定義する必要性が無くなる場合も出てきました。

これらを踏まえ、これから書く Java コードにおいて enum の定義が必要なのか否か、必要であればどういった定義にするべきなのか、よく検討できるようになって貰えれば幸いです。

Discussion