<-- mermaid -->

arrow.core.Either 使い方メモと、初めてのKotlinコードリーディング

はじめに

はじめまして、NewsPicks App Product Unitの池川(いけがわ)と申します。
2022年5月から今のチームにジョインしており、もともとJavaエンジニアである自分が、最近はKotlinを触るようになりました。

そのキャッチアップの中で、NewsPicksの課金基盤で利用されている、arrow.core.Eitherについて調べる機会がありました。

その内容を、社内で定期的に開催されるKotlin知見共有会にて共有することになり、また合わせてTech Blogでも書かせていただく運びになりました。

なお、現在使っているarrowのバージョンは古いです。
このため、Latestでは存在しない機能を利用しており、この記事でもその古い機能を元に記載しております。

バージョンアップは今後対応予定で、バージョンアップもブログに書けたらと思います。

arrow.core.Eitherとは

arrow.core.Eitherは、Haskell,ScalaなどにあるEitherをKotlinで使えるようにしたものです。
自分は、これらの言語について詳しくなかったのですが、社内の方から共有いただいた、以下のドキュメントが参考になりました。

エラー処理 · Scala研修テキスト

Eitherの特徴は、以下の通りです。

  • Right / Leftのどちらか値を持つ。
  • Right = 正常値、 Left = エラー値として扱う。
    • Rightに正常値が入るのは、英語でRightが「正しい」という意味になるのに掛けている、という背景があるようです。
  • 呼び出し元が、Right / Leftなのかを判定し、それぞれ適切に処理を実装する。

Javaですと、単に例外をThrowすることで、異常を呼び出し元に通知することが多いと思いますが、
Kotlinのarrow.core.Eitherでは、それを返り値として扱えるようになる、という違いがあります。

arrow.core.Eitherのサンプル

arrow.core.Eitherを使ったサンプルコードは、以下のようなものです。

import arrow.core.Either
import arrow.core.left
import arrow.core.right

class TechBlog {

    fun main() {
        //Eitherを利用
        when(maySuccessFunction()){
            is Either.Right -> {
                //成功時の後処理
            }
            is Either.Left -> {
                //失敗時の後処理
            }
        }
    }
    private fun maySuccessFunction(): Either<RuntimeException, String> {
        //Eitherを生成
        if (isSuccess) {
            return "success".right()
        } else {
            return RuntimeException("fail").left()
        }
    }
}

arrow.core.Eitherの使い方としては、大きくは2段階構えとなっています。

まず、サンプルの例にある、#maySuccessFunctionのように、何らかの処理を実行し、処理が成功したらAny#right、失敗したらAny#leftを呼び出し、arrow.core.Eitherを作成する処理が必要です。

Anyとは、Kotlinのnon-nullの型の基底クラスで、JavaでいうところのObjectに相当します。
Any#rightAny#leftは、どちらもライブラリ内で、拡張関数として定義されています。

そして、上記で作成したarrow.core.Eitherを利用しているのが、 #mainです。
whenの中で、返り値のarrow.core.Eitherが、Right、Leftのどちらなのかかを判定し、成功時・失敗時の処理を分岐できるようになっています。

これらの生成・利用について、次の節で詳しく見てみようと思います。

arrow.core.Eitherの生成方法

arrow.core.Eitherの生成方法は、大きくは2つあります。

1. Any#left / #right

既出ですが、改めて取り上げます。
このAny#left / #rightは、呼び出したインスタンスをEitherに変換してくれます。

Any?型については、Any?#rightIfNotNullという関数が用意されているようです。
使い方としては以下の通りです。

import arrow.core.Either
import arrow.core.left
import arrow.core.right

private val webApi;

  /**
   * サーバーにUserDataをリクエストします。
   */
fun findUserData(userId : UserId) : Either<ApiException, UserData>{
    val response = webApi.findUserData(userId)
    if (response.status == 200) {
        return UserDataFactory.from(response).right() // 処理成功なので、rightを呼ぶ
    } else {
        return ApiException("Failed to find UserData! reason : ${response.errorMessage}" ).left() // 処理失敗なので、leftを呼ぶ
    }
}

どのオブジェクトからもAny#left / #rightを呼び出し可能なので、Eitherを作成するのは簡単そうです。

2. Either#fx

Either#fxは、生成方法として扱っているのですが、どちらかというと、1で作成したEitherを活用していく機能になっています。
サンプルのコードは以下の通りです。

import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.left
import arrow.core.right

    /**
     * 指定のユーザーで購入処理を実行します
     */
    fun buy(userId: UserId, bookId: BookId): Either<PurchaseException, String> {
        return Either.fx<PurchaseException, String> {
            // ユーザー情報を検索
            val userData = findUserData(userId).bind()

            // ユーザーに紐づくクレジットカード情報を用い、指定の書籍を購入し、レシートを受け取る
            val receipt = buyViaPaymentSystem(userData.creditCard, bookId).bind()

            // 購入後のレシートを永続化
            val history = saveRecieptAsHistory(receipt).bind()

            // ユーザー向けメッセージに加工して返却
            history.toUserMessage()
        }
    }

    // Any#left / #rightを使って以下メソッドを実装しますが、ここでは略します。

    private fun findUserData(userId: UserId): Either<PurchaseException.NotFoundUserDataException, UserData>

    private fun buyViaPaymentSystem(creditCard: CreditCard, bookId: BookId): Either<PurchaseException.InvalidCreditCardException, Receipt>

    private fun saveRecieptAsHistory(receipt: Receipt): Either<PurchaseException.FailedToSaveReceiptException, History>

    // 購入処理周りの例外
    sealed class PurchaseException(message: String) : RuntimeException(message) {
        /**
         * ユーザーが見つかりません
         */
        class NotFoundUserDataException(message: String) : PurchaseException(message)
        /**
         * 購入処理に使うクレジットカードが無効
         */
        class InvalidCreditCardException(message: String) : PurchaseException(message)
        /**
         * 購入後のレシート保存に失敗
         */
        class FailedToSaveReceiptException(message: String) : PurchaseException(message)
    }

ここで注目いただきたいのは、 #buyメソッドの、ユーザーが書籍を購入するという一連の処理です。

  1. ユーザー情報を検索
  2. ユーザー情報が持つクレジットカード情報を使い、書籍を購入し、レシートを取得
  3. レシートを永続化し履歴として保存
  4. ユーザーへの通知メッセージを作成

という処理ですが、各処理は、1つ前の処理のアウトプットに依存しています。
そのアウトプットは、Either<PurchaseException, Hoge>型で、Either#bindメソッドを用いて、Hogeを取得しています。

実は、Either#bindメソッドは、Any#rightで作られたEitherであれば問題なく値を返すのですが、
Any#leftで作られたEither( = Exception)であれば、後続の処理に進むことなく、処理を終了してくれる機能になっています。

例えば、ユーザー情報を検索し、書籍の購入処理までの箇所を抜粋すると、以下のような挙動になります。

// ユーザー情報を検索
val userData = findUserData(userId).bind() // #findUserDataがエラーだった場合、この行で処理が終わり

    // 無事にuserDataが取得できれば、↓の処理に進む

// ユーザーに紐づくクレジットカード情報を用い、指定の書籍を購入し、レシートを受け取る
val receipt = buyViaPaymentSystem(userData.creditCard, bookId).bind()

この機能によって、呼び出し元でif文などを使ってバリデーションを書くことなく、スッキリとした実装にすることができます。

arrow.core.Eitherの利用方法

ここでは、主にEitherをどのようにハンドリングしているかについて書きます。

import arrow.core.Either

    fun main(userIds: List<UserId>, bookId: BookId) {
        userIds.forEach { userId ->
            //ここのbuyメソッドは、既出のメソッドと同じです。
            when (val either = buy(userId, bookId)) {
                is Either.Right -> {
                    notify(userId, either.b)
                }
                is Either.Left -> when (either.a) {
                    is PurchaseException.NotFoundUserDataException -> {
                        println("ユーザー情報が見つからなかった場合のハンドリング")
                    }
                    is PurchaseException.InvalidCreditCardException -> {
                        println("クレジットカード情報がinvalidな場合のハンドリング")
                    }
                    is PurchaseException.FailedToSaveReceiptException -> {
                        println("レシート保存に失敗した場合のハンドリング")
                    }
                }
            }
        }
    }

    /**
     * ユーザーへ通知します。
     */
    private fun notify(userId: UserId, message : String)

    // 購入処理周りの例外
    sealed class PurchaseException(message: String) : RuntimeException(message) {
        /**
         * ユーザーが見つかりません
         */
        class NotFoundUserDataException(message: String) : PurchaseException(message)
        /**
         * 購入処理に使うクレジットカードが無効
         */
        class InvalidCreditCardException(message: String) : PurchaseException(message)
        /**
         * 購入後のレシート保存に失敗
         */
        class FailedToSaveReceiptException(message: String) : PurchaseException(message)
    }

#mainメソッドを見ていただければ、分かると思うのですが、最終的にはEitherがRightなのかLeftなのかを判定して、ハンドリングを行っています。
Rightの場合、処理が正常終了したということなので、処理を完了、もしくは後続の処理を実行する実装をしています。

Leftの場合、何らかの異常が発生しているので、そのハンドリングが必要になります。

このハンドリングに便利なのが、 sealed で宣言されたclassです。
サンプルコードでいうところの下記です。

    // 購入処理周りの例外
    sealed class PurchaseException(message: String) : RuntimeException(message) {
        /**
         * ユーザーが見つかりません
         */
        class NotFoundUserDataException(message: String) : PurchaseException(message)
        /**
         * 購入処理に使うクレジットカードが無効
         */
        class InvalidCreditCardException(message: String) : PurchaseException(message)
        /**
         * 購入後のレシート保存に失敗
         */
        class FailedToSaveReceiptException(message: String) : PurchaseException(message)
    }

IDEによっては、whenに渡されたインスタンスの型がsealedで宣言されている場合、自動でクラス判定処理を過不足なく補完してくれます。
※ IntelliJでは、この機能がサポートされていました。

sealedで宣言された例外クラスにて、適切にエラーを表現することで、エラーハンドリングも適切な表現になることが期待できます。

arrow.core.Eitherに関するまとめ

arrow.core.Eitherを利用することで、Kotlinでの例外処理の実装の幅が広がるのではと思います。

Scalaに関する以下の指摘を引用しますが、いつもの例外だけ or arrow.core.Eitherだけというのではなく、
実装の目的に照らして適切な方法を選択するのが良いのではと思いました。

Scalaでのエラー処理は例外を使う方法と、OptionやEitherやTryなどのデータ型を使う方法があります。この2つの方法はどちらか一方だけを使うわけではなく、状況に応じて使いわけることになります。

引用 : https://scala-text.github.io/scala_text/error-handling.html

Kotlinソースをデコンパイル

ところで、

実は、Either#bindメソッドは、Any#rightで作られたEitherであれば問題なく値を返すのですが、
Any#leftで作られたEither( = Exception)であれば、後続の処理に進むことなく、処理を終了してくれる機能になっています。

という一文について、特にJavaに慣れ親しんでいる方には奇妙に見えないでしょうか?
少なくとも自分は、コードを読んでいるだけでは理解できず、ユニットテスト・デバッグにてカーソルを追うことで理解はしました。

ただ、実際はそうであっても、なぜそうなるのかというところが分からず、気になっていたので、
KotlinがJVM言語ということを利用して、生成されるclassファイルをデコンパイルして確認してみることにしました。

まずはbuyメソッドを見てみます。

  @NotNull
  public final Either<PurchaseException, String> buy(@NotNull String userId, @NotNull String bookId) {
    Intrinsics.checkParameterIsNotNull(userId, "userId");
    Intrinsics.checkParameterIsNotNull(bookId, "bookId");
    return EitherKt.fx(Either.Companion, new TechBlog$buy$1(userId, bookId, null));
  }

Either#fxメソッドに渡されていたラムダが、コンパイル後は、TechBlog$buy$1という内部クラスに変換されています。
次は、この内部クラスを見てみます。

  static final class TechBlog$buy$1 extends RestrictedSuspendLambda implements Function2<MonadSyntax<Kind<? extends ForEither, ? extends PurchaseException>>, Continuation<? super String>, Object> {
    private MonadSyntax p$;
    
    Object L$0;
    
    Object L$1;
    
    Object L$2;
    
    int label;

    @Nullable
    public final Object invokeSuspend(@NotNull Object $result) {
      MonadSyntax $this$fx;
      TechBlog.UserData userData;
      TechBlog.Receipt receipt;
      TechBlog.History history;
      Object object = IntrinsicsKt.getCOROUTINE_SUSPENDED();
      switch (this.label) {
        case 0:
          ResultKt.throwOnFailure($result);
          $this$fx = this.p$;
          this.L$0 = $this$fx;
          this.label = 1;
          if ($this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this) == object)
            return object; 
          userData = (TechBlog.UserData)$this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this);
          this.L$0 = $this$fx;
          this.L$1 = userData;
          this.label = 2;
          if ($this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this) == object)
            return object; 
          receipt = (TechBlog.Receipt)$this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this);
          this.L$0 = $this$fx;
          this.L$1 = userData;
          this.L$2 = receipt;
          this.label = 3;
          if ($this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this) == object)
            return object; 
          history = (TechBlog.History)$this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this);
          return history.toUserMessage();
        case 1:
          $this$fx = (MonadSyntax)this.L$0;
          ResultKt.throwOnFailure($result);
          userData = (TechBlog.UserData)$result;
          this.L$0 = $this$fx;
          this.L$1 = userData;
          this.label = 2;
          if ($this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this) == object)
            return object; 
          receipt = (TechBlog.Receipt)$this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this);
          this.L$0 = $this$fx;
          this.L$1 = userData;
          this.L$2 = receipt;
          this.label = 3;
          if ($this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this) == object)
            return object; 
          history = (TechBlog.History)$this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this);
          return history.toUserMessage();
        case 2:
          userData = (TechBlog.UserData)this.L$1;
          $this$fx = (MonadSyntax)this.L$0;
          ResultKt.throwOnFailure($result);
          receipt = (TechBlog.Receipt)$result;
          this.L$0 = $this$fx;
          this.L$1 = userData;
          this.L$2 = receipt;
          this.label = 3;
          if ($this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this) == object)
            return object; 
          history = (TechBlog.History)$this$fx.bind((Kind)TechBlog.this.saveRecieptAsHistory(receipt), (Continuation)this);
          return history.toUserMessage();
        case 3:
          receipt = (TechBlog.Receipt)this.L$2;
          userData = (TechBlog.UserData)this.L$1;
          $this$fx = (MonadSyntax)this.L$0;
          ResultKt.throwOnFailure($result);
          history = (TechBlog.History)$result;
          return history.toUserMessage();
      } 
      throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
    }

    TechBlog$buy$1(String param1String1, String param1String2, Continuation param1Continuation) {
      super(2, param1Continuation);
    }
    
    @NotNull
    public final Continuation<Unit> create(@Nullable Object value, @NotNull Continuation completion) {
      Intrinsics.checkParameterIsNotNull(completion, "completion");
      TechBlog$buy$1 techBlog$buy$1 = new TechBlog$buy$1(((TechBlog$buy$1)super).$userId, ((TechBlog$buy$1)super).$bookId, completion);
      techBlog$buy$1.p$ = (MonadSyntax)value;
      return (Continuation<Unit>)techBlog$buy$1;
    }
    
    public final Object invoke(Object param1Object1, Object param1Object2) {
      return ((TechBlog$buy$1)create(param1Object1, (Continuation)param1Object2)).invokeSuspend(Unit.INSTANCE);
    }
  }

思っていたより、とても長大ですね。
今回は、Eitherの使い方がテーマのブログなのですが、少しだけ読もうと思います。

2回呼ばれる#bindメソッドの謎

コードを読んでいると、#invokeSuspendの処理が、Kotlinで実装した書籍の購入処理を表しているように見えますが、素直に上から下に実行するのは難しそうに見えます。

ひとまず#invokeSuspendを読むと、this.labelの値によって処理を分岐させていることがわかります。

    public final Object invokeSuspend(@NotNull Object $result) {
      // 略
      switch (this.label) {
        case 0:
        //略
    
        case 1:
        //略
    
        case 2:
        //略
    
        case 3:
        //略
      }
      throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");

this.labelが何者かわからないため、続けてcase 0 のブロックを読んでいると、#findUserData#buyViaPaymentSystemなど、Either#fx内で呼んでいた処理を2回呼ぶかのような実装になっていることが分かります。

    // #findUserDataが2回実行されている箇所の抜粋 
    if ($this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this) == object)
        return object; 
    userData = (TechBlog.UserData)$this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this);

本当に2回呼んでいれば、致命的なバグになりそうです。
ただ、実際に呼び出し先メソッドにカウンターを仕込んで確認すると、1回しか呼ばれていませんでした。

このため、実際の挙動としては、#bind自体は、objectインスタンスと同じIntrinsicsKt#getCOROUTINE_SUSPENDEDの値を常に返却しており、直後のreturnが毎回呼ばれているため処理を2回呼ぶようなことは発生しないのではと思われました。

//objectの宣言
Object object = IntrinsicsKt.getCOROUTINE_SUSPENDED();

// case 0 ブロックの抜粋

this.label = 1; // labelを更新

// ここは常にtrueになるように実装されているように見える
if ($this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this) == object) 
    return object; 

//ここは到達しないように見える
userData = (TechBlog.UserData)$this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this); 

caseブロック間の比較

ところで、case 0case 1を比較すると、case 1では、userData変数に対し、引数の$resultをキャストして渡していることが分かります。

// case 1 ブロックの抜粋
userData = (TechBlog.UserData)$result;

userData自体は、#findUserDataがインスタンスを生成するので、case 1のブロックを実行する際に渡される$resultは、case 0ブロックで生成したものではないかという予想ができそうです。

    // case 0 の抜粋
    case 0:
        // 略
        // $this$fx.bindに対し、#findUserDataの返り値を渡している
        if ($this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this) == object)
            return object; 
        //略
    case 1:
        //略
        // この$resultは、case 0ブロックで作られた#findUserDataの返り値から取り出した、UserDataが渡されているのでは
        userData = (TechBlog.UserData)$result;

また、#findUserDataメソッド自体は、Eitherを返却しているため、$resultへの代入は、Eitherから値を取得する処理をライブラリ側で実装をしているのではという予想もできます。

これが正しいとすると、#invokeSuspendの実行前に、直前でbindされたEitherがRightかどうかを検証しており、この検証処理がエラー時に処理を中断させる機能を実現しているのでは、という仮設を立てることもできます。

// invokeSuspendの一部抜粋とコメント

// 直前に$this$fxに渡されたEitherがRightの場合、ライブラリが値を取り出して、$resultとして扱っているのでは
public final Object invokeSuspend(@NotNull Object $result) {
    // 1回目のinvokeSuspendの実行
    case 0:

        this.label = 1;
        // ここは常にtrueになるように実装されているように見える
        // また、`TechBlog.this.findUserData(this.$userId)`が返すEitherは、$this$fxに渡されることで、#invokeSuspendの呼び出し元がそのRight / Left判定を行っているように思われる
        if ($this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this) == object) 
            return object; 
        //ここは到達しないように見える
        userData = (TechBlog.UserData)$this$fx.bind((Kind)TechBlog.this.findUserData(this.$userId), (Continuation)this); 

    // 2回目のinvokeSuspendの実行
    case 1:

        // case 0 ブロックで作成されたインスタンスのように見える
        userData = (TechBlog.UserData)$result; 

        this.label = 2;

        // ここは常にtrueになるように実装されているように見える
        // また、`TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId)`が返すEitherは、$this$fxに渡されることで、#invokeSuspendの呼び出し元がそのRight / Left判定を行っているように思われる
        if ($this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this) == object) 
            return object; 
        //ここは到達しないように見える
        receipt = (TechBlog.Receipt)$this$fx.bind((Kind)TechBlog.this.buyViaPaymentSystem(userData.getCreditCard(), this.$bookId), (Continuation)this); 
    // 略
    // 3回目のinvokeSuspendの実行
    case 2:
    // 略
    // 4回目のinvokeSuspendの実行
    case 3:
    // 略
    // すべてのbind処理が正常終了したので、最後の処理を実行
        history = (TechBlog.History)$result;
        return history.toUserMessage();

このように各caseブロックで、#bindを実行するごとに処理の一時停止と再実行を繰り返しながら、Either#fxでの#bind呼び出しを安全に処理しているのでは、と仮設を立ててみました。

本来は、ここから更にライブラリのコードなどを読んで、検証していきたいのですが、
このブログ自体は、arrow.core.Eitherに関するメモなので、これ以降は割愛いたします。

(なぜこのような複雑なclassファイルが出来上がるのか、という点も同様です。)

コードリーディングのまとめ

arrow.core.EitherやKotlinが簡潔な処理を書かせてくれる裏側で、煩雑な処理を実行する役割を担っていそうだということが分かるのではないでしょうか?

また、Javaしか触れていなかった方が、Kotlinの一見不思議な挙動を理解するのに、デコンパイルされたコードを読んでいくのは、その手助けにもなりそうだと思いました。
(自分は、デコンパイルされたコードを読むことで納得感が増しました。)

ブログのまとめ

今回はarrow.core.Eitherについて、まとめさせていただきました。
関心のある方は、以下のGithubを参考に、ローカル環境でお試しいただくことをおすすめいたします。

github.com

お試しいただくときは、arrow-ktのドキュメントをご参考いただくようお願いいたします。
(本ブログで扱ったバージョンは古いので、お気をつけください。)

最後に、参考にしたページを記載させていただきます。

Λrrow Core.
https://arrow-kt.io/docs/core/

arrow-kt/arrow: Λrrow - Functional companion to Kotlin's Standard Library.
https://github.com/arrow-kt/arrow

[archived] arrow-kt/arrow-core: Λrrow Core is part of Λrrow, a functional companion to Kotlin's Standard Library.
https://github.com/arrow-kt/arrow-core

Java Decompiler.
http://java-decompiler.github.io/

エラー処理 · Scala研修テキスト
https://scala-text.github.io/scala_text/error-handling.html

Page top