- はじめに
- arrow.core.Eitherとは
- arrow.core.Eitherのサンプル
- arrow.core.Eitherの生成方法
- 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で使えるようにしたものです。
自分は、これらの言語について詳しくなかったのですが、社内の方から共有いただいた、以下のドキュメントが参考になりました。
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#right
、Any#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つ前の処理のアウトプットに依存しています。
そのアウトプットは、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 0
とcase 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を参考に、ローカル環境でお試しいただくことをおすすめいたします。
お試しいただくときは、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