Kleisli Arrow で可否判定

2024/12/06に公開

はじめに

システムに対してユーザーが何か申し込みをしようとしたときに、ユーザーの状態を見てリクエストを受けられるかどうか判定する、というユースケースを考えてみます。

例として音楽サービスを考えてみます。A社はいくつかのサービスを提供していて、サービスの一つに音楽配信サービスがあります。ユーザーはA社サービスの会員になり、その上で音楽配信サービスに申し込むことで音楽を楽しむことができます。このようなサービスを申し込みできるか判定する処理を作ることを考えます。

ふつうに Java で実装する

このサービスのモデルをJavaで書くと以下のようになるでしょう。

record MemberId(String value) {}

enum MemberState {
    Enrolled, Withdrawn
}

record Member(MemberId id, String memberName, MemberState state) {}

record MusicStreamingContractId(String value) {}

enum MusicStreamingContractState {
    UnderContract, Terminated
}

record MusicStreamingContract(MusicStreamingContractId contractId, MemberId memberId, MusicStreamingContractState state) {}

音楽配信サービスを申し込めるかどうかの判定は以下の条件を全て満たした場合となります。

  • 会員が存在する
  • 会員の状態が入会済み
  • 音楽配信サービスに入っていないか、サービスを解約済み

これを判定するメソッドを作ってみます。メソッドが呼び出されるのは、サービス申し込みのリンクを表示するかどうかの判定をする際と、実際申し込みを行う際に状態が変わっていないことを確認するために行う2箇所を想定しています。

判定の対象会員は memberId によって識別され、戻り値として Either<String, Member> を返し、申し込み可であれば Either.right を、申し込み不可であれば Either.left を返す、というインターフェースとします。

Either は標準のクラスではありませんが、結果が成功か失敗かを表現しつつ、成功の場合と失敗の場合にそれぞれ追加情報を返したい場合に便利なクラスです。ここでは vavr というライブラリに含まれるものを使用します。

成功したときに Member を返しているのは、この判定の後申し込み処理を進める場合には必要となる情報であるためです。Member を後から利用したくなったときに、リポジトリ(の先のデータベース)からの再取得を避けることができます。

失敗したときにはこのサンプルコードでは String でエラーメッセージを表現することにしておきます。

import io.vavr.control.Either;
import io.vavr.control.Option;

Either<String, Member> musicStreamingApplicable(MemberId memberId) {
    // 現在の会員や契約の状態は別途用意されたリポジトリから取得するものとする
    Option<Member> memberOpt = memberRepository.findMemberById(memberId);
    Option<MusicStreamingContract> musicContractOpt = musicStreamingContractRepository.findMusicContractByMemberId(memberId);

    if (memberOpt.isEmpty()) {
        return Either.left("Member not exist");
    }
    Member member = memberOpt.get();
    if (member.state() != MemberState.Enrolled) {
        return Either.left("Member not enrolled");
    }
    if (musicContractOpt.isDefined() && musicContractOpt.get().state() == MusicStreamingContractState.UnderContract) {
        return Either.left("Music is under contract");
    }
    return Either.right(member);
}

これはシンプルな例ですが、if の連鎖でサービス仕様を書き下して条件判定をしていて、このままだと似たような申し込み判定が増えたときに再利用しづらそうです。

申し込み判定処理を合成可能にする

判定処理では、MemberId から Member を取り、その Member に対して状態判定をしたり、MemberId から MusicStreamingContract を取りその状態を見たりといったことをしています。この入力と出力の関係に着目して、 Validator というクラスを考えてみます。

interface Validator<A, E, B> {
    Either<E, B> validate(A arg);
}

Validator は A を入力として何か判定をして、成功すれば B を出力し、失敗すれば E を出力する、というものです。この interface を実装し、MemberId を入力としてそのような会員が存在するか判定し、存在したら Member を返す、というオブジェクトが作れます。

class MemberValidators {
    // memberRepositoryの定義は省略します

    // 会員が存在するか
    public Validator<MemberId, String, Member> exists() {
        return memberId -> memberRepository.findMemberById(memberId)
                .fold(() -> Either.left("Member not exist"), Either::right);
    }
}

さらに Member を入力として、その会員が入会済みかを判定し、入会済みであれば Member をそのまま返す、というオブジェクトを作ります。

class MemberValidators {
    // (省略)

    // 会員が入会済みか
    public Validator<Member, String, Member> enrolled() {
        return member -> switch (member.state()) {
            case MemberState.Enrolled -> Either.right(member);
            case MemberState.Withdrawn -> Either.left("Member not enrolled");
        };
    }
}

Validator に andThen というメソッドを用意することで、二つのメソッドを合成できるようになります。

interface Validator<A, E, B> {
    Either<E, B> validate(A arg);

    // this を適用した結果に that を適用する Validator を返す
    default <C> Validator<A, E, C> andThen(Validator<B, E, C> that) {
        return arg -> this.validate(arg).flatMap(that::validate);
    }
}

andThen は this の Validator を適用した結果に引数 that で与えられた Validator を適用します。andThen を使うことで existsenrolled を合成できます。

// 会員が存在していてかつ状態が入会済みか
Validator<MemberId, String, Member> memberExistsAndEnrolled() {
    return memberValidators.exists().andThen(memberValidators.enrolled());
}

また、「音楽配信サービスに入っていないか、サービスを解約済み」は、音楽配信サービスに入っているまたは入っていたことを判定する hasContract とサービスを契約中であることを判定する underContract を用意し、それらを合成したものを反転することで作れそうです。

この方針で作ると、hasContractunderContract は次のようになります。

public class MusicValidators {
    // 音楽配信サービスに入っているまたは入っていたことがあるか
    public Validator<MemberId, String, MusicStreamingContract> hasContract() {
        return memberId -> musicStreamingContractRepository.findMusicContractByMemberId(memberId)
                .fold(() -> Either.left("No music contract"), Either::right);
    }

    // 音楽配信サービスを契約中か
    public Validator<MusicStreamingContract, String, MusicStreamingContract> underContract() {
        return musicContract -> musicContract.state() == MusicStreamingContractState.UnderContract ?
                Either.right(musicContract) :
                Either.left("Music is not under contract");
    }

Validator に、Either を入力としてその値が left か right によって通す先の Validator を変える fanIn を用意します。入力が left だったらその値を leftValidator に入力した結果が返り、入力が right だった場合はその値を rightValidator に入力した結果が返ります。

interface Validator<A, E, B> {
    // (省略)

    // 入力が left なら leftValidator を適用し、
    // 入力が right なら rightValidator を適用する Validator を返す
    static <A, B, C, E> Validator<Either<A, B>, E, C> fanIn(
            Validator<A, E, C> leftValidator,
            Validator<B, E, C> rightValidator) {
        return (Either<A, B> e) -> e.fold(leftValidator::validate, rightValidator::validate);
    }
}

これらを組み合わせると、「音楽配信サービスに入っていないか、サービスを解約済み」を表す notUnderContract は次のようになります。

// 音楽配信サービスに入っていないか、サービスを解約済み
Validator<MemberId, String, Void> notUnderContract() {
    return Validator
            .<MemberId, Either<String, MusicStreamingContract>, String>valid(
                    x -> hasContract().andThen(underContract()).validate(x)
            )
            .andThen(Validator.fanIn(
                    Validator.valid(_ -> null),
                    Validator.invalid(_ -> "Music is under contract")
            ));
}

Validator.validValidator.invalid は、関数を Validator にするものです。Validator.valid は必ず成功するとみなして、right 値として関数の値を返します。Validator.invalid は必ず失敗するとみなして、left 値として関数の値を返します。

それぞれ次のような定義です。

interface Validator<A, E, B> {
    // (省略)

    static <A, B, E> Validator<A, E, B> valid(Function<A, B> f) {
        return arg -> Either.right(f.apply(arg));
    }

    static <A, B, E> Validator<A, E, B> invalid(Function<A, E> f) {
        return arg -> Either.left(f.apply(arg));
    }
}

さて、 memberExistsAndEnrollednotUnderContract を組み合わせれば申し込み判定処理が作れます。ただ、どちらも MemberId を入力として必要とするため、入力を複製して渡してあげる必要があります。

入力を複製して 2つの Validator に入れ、それらの結果を Tuple (値のペア)に入れて返してくれる Validator を作る、combine というメソッドを用意します。

interface Validator<A, E, B> {
    // (省略)

    // v を Tuple の最初の要素に適用する
    static <A, B, C, E> Validator<Tuple2<A, C>, E, Tuple2<B, C>> first(Validator<A, E, B> v) {
        return arg -> v.validate(arg._1).map(fst -> Tuple.of(fst, arg._2));
    }

    // v を Tuple の2番目の要素に適用する
    static <A, B, C, E> Validator<Tuple2<A, B>, E, Tuple2<A, C>> second(Validator<B, E, C> v) {
        return arg -> v.validate(arg._2).map(snd -> Tuple.of(arg._1, snd));
    }

    // 入力を複製して v1 と v2 に入力し、それぞれの結果を Tuple にして返す
    static <A, B, C, E> Validator<A, E, Tuple2<B, C>> combine(
            Validator<A, E, B> v1,
            Validator<A, E, C> v2) {
        // valid の型を推論してくれないため明示が必要
        return Validator.<A, Tuple2<A, A>, E>valid(x -> Tuple.of(x, x)) // 入力を複製して Tuple 化
                .andThen(first(v1))   // 最初の要素に v1 を適用
                .andThen(second(v2)); // 2番目の要素に v2 を適用
    }
}

これを使用すると申し込み判定をする Validator が作れます。

Validator<MemberId, String, Member> musicStreamingApplicabilityValidator() {
    return Validator.combine(
                    memberExistsAndEnrolled(),
                    notUnderContract()
            )
            .andThen(Validator.valid(Tuple2::_1)); // Tuple の最初の要素を返す
}

ここで combine は Validator<MemberId, String, Tuple2<Member, Void>> を返しますが、Void は不要なので andThen のところで最初の要素だけを取り出しています。

これで3つの条件を組み合わせて申込判定処理を書くことができました。

Kleisli Arrow

この Validator を一般化すると、何かを入力して別の何かを出力するものを表す構造と言えます。このような構造は Arrow という名前が付いています。Arrow の中でも特に、出力にコンテキスト(例えば Either のように失敗するかもしれないというコンテキスト)を持つものは Kleisli Arrow といいます。Validator は Kleisli Arrow でもあります。

Validator が Arrow の一種であるとわかると何が嬉しいのかというと、Arrow でできることは Validator でもできる、と言えることです。

Arrow は複数の入力を扱ったり、入力の一部だけを使用したり、といった計算が可能なクラスです。そのような計算は Validator であっても可能ということになります。Validator に用意したメソッドを組み合わせることで、多数の Validator を複雑に接続した計算も実現可能なのです。

まとめ

Validator を使って、再利用可能な小さな判定部品を合成して複雑な判定処理を作る方法を紹介しました。

Discussion