BIGLOBEの「はたらく人」と「トガッた技術」

代数的データ型をJavaで安全に使いこなす

基盤本部(開発部門)の木下です。前回、Java 17 の新機能を使ってドメイン駆動設計(Domain Driven Design: DDD)のモデリングの表現力を高める例をご紹介しました。

style.biglobe.co.jp

代数的データ型(Algebraic Data Types)を導入するのがポイントなのですが、馴染みのないメンバーも多かったので、実例を使って詳しく解説してみました。関数型プログラミング由来のとても便利な道具です。ぜひ活用してみてください。

代数的データ型とは

代数的データ型とは、基本となる型を組み合わせて作られる型のことです。 代数的データ型は直和型と直積型の2つからなります。

直積型

直積型のほうが馴染み深い概念なのでこちらから説明します。

直積型というのは、いくつかの型を同時に保持するような型を言います。 Java では、 class が直積型です。

class Person {
    String name
    int age
}

Person は name と age を両方保持しています。 特に新しい概念はないと思いますが、こういうものが直積型と呼ばれるもの(の一つ)です。

言語によってはペアとかタプルという機能があるものもありますが、これも直積型の一種です。

直和型

直和型というのは、いくつかの型のうちの一つだけを保持するような型です。

例えば何かの処理の結果を表すために、成功したときは処理結果を保持し、失敗したとき は失敗理由を保持したいとします。 直和型を使うと、以下の情報を一つの型で保持することができます。

  • 成功したのか失敗したのかどちらなのか
  • 成功した場合は処理結果
  • 失敗した場合は失敗理由

場合によって異なる型を持つような型がほしい、というのが直和型の要求です。 直和型は直積型と違って Java に直接対応する機能はありません。 なので Java でわざわざ代数的データ型と言う場合は直和型について議論したい場合が多いです。

直和型の Java での実装

Java でがんばって直和型を実装するにはどうしたら良いか説明していきます。

ベタに class で表現してみる

先ほどの成功失敗の例をもう少し具体的にして、整数除算の結果を表現したいとしましょう。

除算の仕様は以下とします。

  • 負の数を習っていない小学生のために、除数・被除数ともに負でない整数とし負の数が来たらエラー
  • まだ余りを習っていない(略)、ちょうど割り切れない場合はエラー
  • 除数が0の場合はエラー

成功か失敗かを示すタグを持たせて (isSuccess) 、答えとエラー Optional で並べる実装例です。

enum DivError {
    NEGATIVE_DIVISOR_OR_DIVIDEND,
    NOT_DIVISIBLE,
    DIVISION_BY_ZERO,
}
record DivResult(
        boolean isSuccess,             // 成功か失敗か
        Optional<Integer> quotient,    // 成功した場合の商
        Optional<DivError> divError) { // 失敗した場合の理由
    static DivResult success(int quotient) {
        return new DivResult(true, Optional.of(quotient), Optional.empty());
    }

    static DivResult failure(DivError divError) {
        return new DivResult(false, Optional.empty(), Optional.of(divError));
    }
}

除算の実装は次のように書けます。

DivResult div(int dividend, int divisor) {
    if (divisor == 0) {
        return DivResult.failure(DivError.DIVISION_BY_ZERO);
    }
    if (dividend < 0 || divisor < 0) {
        return DivResult.failure(DivError.NEGATIVE_DIVISOR_OR_DIVIDEND);
    }
    if (dividend % divisor != 0) {
        return DivResult.failure(DivError.NOT_DIVISIBLE);
    }
    return DivResult.success(dividend / divisor);
}

div メソッドを使う側は次のように書けるでしょう。

String testDiv(int dividend, int divisor) {
    DivResult result = div(dividend, divisor);
    if (result.isSuccess()) {
        return "答え: %d".formatted(result.quotient().get());
    } else {
        return switch (result.divError().get()) {
            case NEGATIVE_DIVISOR_OR_DIVIDEND -> "除数または被除数が負";
            case NOT_DIVISIBLE -> "割り切れない";
            case DIVISION_BY_ZERO -> "0による除算";
        };
    }
}

機能は実装できていますが、以下のような点に課題があります。

  • DivResult 型がドメインが取るべき以外の状態もとれる
    • Optional のどちらか一方にしか値が入っていないということが表現できていない
    • すなわち、quotient と divError の両方に値を入れることもできるし、両方 none にすることもできてしまう
  • 利用側が気を付けないと不正なアクセスができてしまう
    • success/failure にかかわらず quotient, divError が Optional でしか取れないので、 間違ったほうを Optional.get() すると例外発生

2つのクラスと interface で実現

最初の課題 DivResult 型がドメインが取るべき以外の状態も取れてしまう問題に対処します。

成功・失敗の2つの状態があるので、その2つでクラスを作ります。

record DivResultSuccess(int quotient) {}
record DivResultFailure(DivError divError) {}

2つのクラスに分かれていると使うときに困るので、共通の interface を作って1つの型として扱えるようにします。 前節と同じように success()failure() でインスタンスを作れるようにします。

interface DivResult {
    static DivResult success(int quotient) {
        return new DivResultSuccess(quotient);
    }
    static DivResult failure(DivError divError) {
        return new DivResultFailure(divError);
    }
}
record DivResultSuccess(int quotient) implements DivResult {}
record DivResultFailure(DivError divError) implements DivResult {}

2つの record 型は内部的なものなので interface の inner class にするとわかりやすくなります。

また、この interface を継承して別の状態を作られたりすると困るので、 sealed を付けてこの2つの状態以外を実装できないようにします。 内部クラスなので冗長な名前は短くしました。

sealed interface DivResult permits DivResult.Success, DivResult.Failure {
    static DivResult success(int quotient) {
        return new Success(quotient);
    }
    static DivResult failure(DivError divError) {
        return new Failure(divError);
    }
    record Success(int quotient) implements DivResult {}
    record Failure(DivError divError) implements DivResult {}
}

これで、2つの状態のどちらかだけを持つクラスが実現できました。

安全に利用できるメソッドを提供する

2つ目の課題であった、利用側が気を付けないと不正なアクセスができてしまう問題ですが、前節の sealed 版 DivResult 利用すると次のようになります。

String testDiv(int dividend, int divisor) {
    DivResult result = div(dividend, divisor);
    if (result instanceof DivResult.Success success) {
        return "答え: %d".formatted(success.quotient());
    } else if (result instanceof DivResult.Failure failure) {
        return switch (failure.divError()) {
            case NEGATIVE_DIVISOR_OR_DIVIDEND -> "除数または被除数が負";
            case NOT_DIVISIBLE -> "割り切れない";
            case DIVISION_BY_ZERO -> "0による除算";
        };
    } else {
        throw new RuntimeException("状態不明");
    }
}

Java 16 以降で使える instanceof のパターンマッチング機能を使っているので Optional のときのように例外が起こるようなことはありません。 しかし網羅性検証ができていないので、余計な else 節を書かなければなりません。 また片方の状態の分岐を書き忘れてもエラーにはなってくれません。

このような場合、社内では mapEither というメソッドを作ることがよく行われています。

DivResult に mapEither を追加します。 関数を2つ渡すと今の状態に合致したほうの関数だけが呼ばれる、というメソッドです。

sealed interface DivResult permits DivResult.Success, DivResult.Failure {
    <T, R> R mapEither(
            Function<Success, ? extends R> successMapper, // 状態が成功のときに呼ばれる関数
            Function<Failure, ? extends R> failureMapper  // 状態が失敗のときに呼ばれる関数
    );
    static DivResult success(int quotient) {
        return new Success(quotient);
    }
    static DivResult failure(DivError divError) {
        return new Failure(divError);
    }
    record Success(int quotient) implements DivResult {
        @Override
        public <T, R> R mapEither(
                Function<Success, ? extends R> successMapper,
                Function<Failure, ? extends R> failureMapper) {
            return successMapper.apply(this);
        }
    }
    record Failure(DivError divError) implements DivResult {
        @Override
        public <T, R> R mapEither(
                Function<Success, ? extends R> successMapper,
                Function<Failure, ? extends R> failureMapper) {
            return failureMapper.apply(this);
        }
    }
}

典型的な使い方は、ラムダで成功時の関数と失敗時の関数を渡します。

String testDiv(int dividend, int divisor) {
    DivResult result = div(dividend, divisor);
    return result.mapEither(
            success -> "答え: %d".formatted(success.quotient()),
            failure -> switch (failure.divError()) {
                case NEGATIVE_DIVISOR_OR_DIVIDEND -> "除数または被除数が負";
                case NOT_DIVISIBLE -> "割り切れない";
                case DIVISION_BY_ZERO -> "0による除算";
            }
    );
}

success と failure に書き忘れがないかはコンパイラがチェックしてくれるようになりました。

ちなみに switch のパターンマッチング という Java のプレビュー機能を使うと mapEither のようなものを用意しなくてもきれいに書けるようになります。 次の LTS である Java 21 に入ってくれると嬉しいですね。

String testDiv(int dividend, int divisor) {
    DivResult result = div(dividend, divisor);
    return switch (result) {
        case DivResult.Success success -> "答え: %d".formatted(success.quotient());
        case DivResult.Failure failure -> switch (failure.divError()) {
            case NEGATIVE_DIVISOR_OR_DIVIDEND -> "除数または被除数が負";
            case NOT_DIVISIBLE -> "割り切れない";
            case DIVISION_BY_ZERO -> "0による除算";
        };
    };
}

ここまで紹介した代数的データ型 / 直和型を使うことで、モデリングの精度を上げることができます。DDDのドメインサービスとアプリケーションサービスをきれいに分離する例について以前書いたので、ぜひ読んでみてください。

style.biglobe.co.jp

おわりに

BIGLOBE では、DDD を活用したサービス・アーキテクチャの設計・開発に取り組んでいます。この記事を読んで、より良いDDDについて考えてみたい方、DDD に興味を持ち挑戦したいと思った方、BIGLOBE で一緒に取り組んでみませんか?ぜひカジュアル面談にいらしてください。

hrmos.co

※ Javaは、Oracle Corporationおよびその子会社、関連会社の米国およびその他の国における登録商標です。