/var/log/jsoizo

メモ帳 技術とか趣味とか

(翻訳) Kotlinでの型付きエラー処理

この記事は以下のブログの翻訳記事で、著者の許諾のもと翻訳しています。翻訳過程で機械翻訳を活用しており、原典と翻訳を十分に照らし合わせて内容が正しいことを確認しておりますが、細かいニュアンスなどが気になる場合は原典をご確認ください。また、誤訳などあればtwitterにてご連絡いただけたら修正いたします。

medium.com

目次

以下訳文となります。


エラー処理は難しいものではありません。エラー処理自体はシンプルな問題ではありますが必ずしもわかりやすいコードになるとは限りません。この記事ではKotlinでよく話題に上がるエラー操作についての実用的な例と、それらがプログラムの認知的な複雑さと保守性に与える影響について考えます。

イントロ

エラー処理戦略を選ぶことは、大規模なソフトウェア開発プロジェクトの成否を左右する最も基本的な決定事項の1つです。エラー処理戦略の不備は、コードの読みやすさだけでなくソフトウェア開発プロジェクト自体の信頼性や長期的なコードの保守性にも影響を及ぼします。そのためプロのソフトウェアチームは、どのような戦略を使用するかを決定する際に、一旦立ち止まり、トレードオフを客観的に分析したうえで意識的にエラー処理戦略を決定する必要があります。また、戦略の決定が、ソフトウェアエンジニアリングのベストプラクティスだけでなく、自身で定めている方向性と合っていることを確認する必要もあります。そのうえで、ソフトウェアチームにとってシンプルで開発者の生産性を向上させるには、簡潔で読みやすく簡単に理解できるようにすることが有益だと言えるでしょう。

プロのソフトウェア開発者にとって費やされる時間と労力の大部分は、プログラムを読み、理解することにあります。読みやすく理解しやすいコードはそうでないコードよりも保守されやすく、正しい状態を維持しやすいことが、研究によって明らかにされています。理解しやすいコードは開発者の生産性を測る様々な指標と直接的に相関しています。これには、タスクを完了するのにかかった時間、提供されたソリューションの正しさ、被験者の心理的な幸福感などが含まれます。

2018年にSonarSourceは循環的複雑度と呼ばれる指標を提案しました。この指標には、理解しやすさの低下につながる精神的/認知的負荷をもたらすとされているコードのいくつかの側面について考慮されています。そこには、条件分岐の場所、条件文、ループ、パターンマッチ、その他多数が含まれており、それぞれがネストの深さに対して重みがあります。

簡潔さと有効性が、よりよい認知と読みやすさのための2つの重要な指標です。複雑さは、機能を複数の小さなクラス、関数、サブルーチンに分割しすぎることから生じることもあります。これは、複雑すぎる、大きすぎる、またはネストが深くなっているプログラムによる脳の過負荷の副産物であることが多いです。プログラムを分割しすぎると、特定の機能を理解するために多くの小さな機能の間をジャンプする必要があるため、全体として複雑さが増す可能性があります。

例外(Exceptions)

制御フローに例外を使用することは、しばしばバッドプラクティスとみなされます。 例外を使いすぎるとデバッグが困難なプログラムとなる傾向にあり、性能の低いプログラムを作成することにもなります。 これらの理由から、Kotlinのようなモダンな言語ではこのようなスタイルのプログラミングは推奨されません。

またKotlinはチェック例外の機能がありません。 このスタイルで書かれたプログラムは、エラーがどのように発生したのかのシナリオを隠してしまいます。また、型シグネチャだけでは、その関数が投げるかどうかを理解することはできません。

interface PetService {
  suspend fun updatePet(...): Pet // 継承先が例外をスローする
}

それでも、例外ベースのロジック制御をKotlinアプリケーションで実用化する開発者もいます。この手法を支持する人としては、この手法は導入が簡単であるということと、適切なテスト体制によって適切に管理されれば同じように機能するということを主張するでしょう。

型付きエラー処理(Typed Error Handling)

Kotlinでは型付きエラー処理(関数型エラー処理とも呼ばれる)と呼ばれる手法を推奨しています。このアプローチはドメインエラーの境界を明示的に型付けされた構成要素としてモデル化し、コンパイル時に契約の正しさを保証することによってバグを最小限に抑えるのに役立ちます。

You should design your own general-purpose Kotlin APIs in the same way: use exceptions for logic errors, type-safe results for everything else. Don’t use exceptions as a work-around to sneak a result value out of a function. […] This way, your caller will have to handle the error condition right away and you avoid writing try/catch in your general application code. — https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07

実際には、この勧告に適応するためにさまざまなアプローチが取られています。一般的なアプローチのひとつが結果をsealed classとしてモデル化することで、これらの型に失敗・成功のタグが付けられることもあります。

interface PetService {
  suspend fun updatePet(...): UpdatePetDetailsResult
}

// type 1: フラットなsealed class改装
sealed class UpdatePetDetailsResult {
  data class Success(val pet: Pet) : UpdatePetDetailsResult()
  data object PetNotFound : UpdatePetDetailsResult()
  ...
}

// type 2: 失敗の型についてsealed classをsealed classで継承する
sealed class UpdatePetDetailsResult {
  data class Success(val pet: Pet) : UpdatePetDetailsResult()
  sealed class Failure : UpdatePetDetailsResult() {
    object PetNotFound : Failure()
    ...
  }
}

このアプローチの大きな欠点は、失敗と成功の型が混同されることです。これは、 ドメイン境界の漏洩につながる可能性があります。
(※ 訳注: ドメインの関心事がなぜ漏洩していると言えるのかの理由が理解できなかったので著者であるMitchelさんに確認したところ、 "sealed classでエラーと成功を定義することは、仮にクラス名にFailureやSuccessが入っていたとしてもどちらの型がエラーと成功どちらなのかの判断を呼び出し元に委ねることになってしまうので、ドメインの関心事が漏洩しているのです" とのこと。たしかに...型レベルで失敗と成功を表現することが出来る点でEitherやRaiseの必要性を再認識した。 )

もう1つのよく知られた手法は Either<L, R> のような成功と失敗をモデル化する専用の汎用的なデータ型を使用することです。この方法は、失敗のL型の中でドメインの境界が明確に定義されるため、ドメイン駆動設計を促進します。Eitherは、次のような2つのメンバーを持つsealed classです。

sealed class Either<out L, out R> 
data class Left<L>(val value: L): Either<L, Nothing>()  // エラー
data class Right<R>(val value: R): Either<Nothing, R>() // 成功

これらは独立した具象クラスであるため、定型文の削減やコードベースのエラー処理方法の統一などの利点があります。updatePet関数のシグネチャは以下のようになります。

interface PetService {
  suspend fun updatePet(...): Either<UpdatePetDetailsFailure, Pet>
}

もう1つのアプローチは、失敗時のコンテキスト型を関数にマークすることです。これは概念的には型レベルで動作するJavaのチェック例外と同等と見なすことができます。関数に対してコンテキスト境界 Raise<E> でマークすることで、コンパイラは呼び出し元に対して、関数を呼び出した際に回復できるような正しいコンテキストを提供するよう強制します。このアプローチは、Arrowチームによって KotlinConf '23で発表されました。以下のコードスニペットは、Context Receiversを使用することで可能になります。

interface PetService {
  context(Raise<UpdatePetDetailsFailure>)
  suspend fun updatePet(...): Pet
}

比較研究

この実験では、要件の複雑さの程度が異なる場合に各プラクティスがどのような挙動となるかを理解することを目的としています。ベンチマークに使用した問題は、ペットの詳細を更新するというシンプルなタスクとなっており、要件は以下の通りです。

  1. ペットはデータベースから取得可能である
  2. ペットはマイクロチップが埋め込まれている場合のみ更新可能である — マイクロチップデータはデータベースから取得可能である
  3. マイクロチップは同じペットIDを指すものであれば有効である
  4. ペットには飼い主が必要である — 飼い主情報は同様にデータベースにある
  5. ペットは飼い主によってのみ更新される;
  6. ペット名が更新される際に、空文字列によって更新することはできない
  7. ペットの更新がNotFoundにより失敗した場合、当該ペットが登録解除されたかシステムによって削除されたことによるレースコンディションが発生したということであり、更新は拒否される

すべてのロジックは1つの関数内で行われ、認知的複雑度循環的複雑度、およびコード行数(LOC)が記録されるようにしました。本稿で比較したアプローチは以下の通りです:

  • 例外ベースのロジック制御
  • 早期returnなしのsealed classマッチング
  • 早期returnありのsealed classマッチング
  • Either<L, R> のflatMapチェーン
  • Arrowの either { } ビルダー
  • Arrowの context(Raise<E>) と Context Receivers

最後の3つのアプローチは、Arrow https://github.com/arrow-kt/arrow を使って実現しました。Arrowは、Kotlinコミュニティで人気を博しているユーティリティライブラリです。Thoughtworksは2020年からArrowを採用しており、Kotlinで作業する際にはArrowデフォルトの利用ライブラリとしています。この研究で使用されたArrowのバージョンは1.2.0-RCで、コンテキストレシーバーはKotlin言語としてまだexperimentalな機能でした。Arrowのライブラリはバージョン2.0で大幅に簡略化されるために、Arrowを習得し使用するための参入障壁や初期投資が低くなっています。

コードを含むリポジトリhttps://github.com/myuwono/typed-error-handling-demo です。

github.com

実験結果

さまざまなアプローチの結果がこのセクションにまとめられています。最も認知的に複雑とされたアプローチ(6位)から最も単純なアプローチ(1位)まで、逆順に表示されています。

6位: 早期returnなしのsealed classマッチング

Kotlinではエラー処理に関して、深くネストされたコードやロジック制御を避けることが推奨されています。この方法によるエラー処理の可読性が低いことを見ると、なぜそのように推奨しているかの背後にある理由がはっきりとわかります。

早期returnなしのsealed classマッチング LOC = 49, 認知的複雑度 = 34, 循環的複雑度 = 13.

5位: Either<L, R> のflatMapチェーン

Kotlinでは、Eittherに対するflatMapをチェーンさせることはsealed classのネストと同様の結果をもたらします。 UpdatePetDetailsResult Either<UpdatePetDetailsFailure, Pet> は異なる表現ですが、両方の型は概念的に同等であることに注意してください。この結果は、Kotlinが最初の失敗でそれ以上の進行を中止するセマンティクスを持つプログラムを好む理由と一致します:

Functional code that uses Try monad gets quickly polluted with flatMap invocations. To make such code manageable, a functional programming language is usually extended with monad comprehension syntax to hide those flatMap invocation. […] Adapting [functions used here] to Kotlin style, one can write this code in Kotlin, with the same semantics of aborting further progress on the first failure. — https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md#appendix-why-flatmap-is-missing

Either<L, R> のflatMapチェーン LOC = 31, 認知的複雑度 = 17, 循環的複雑度 = 7.

4位: 例外と再スロー(rethrow)

Kotlinでは型を使ってロジックを制御することが好まれます。しかし、例外を用いたアプローチやその亜種を好む開発者もいます。このコードスニペットは、どちらかというとJava的であり、Javaのチェック例外の世界でのプログラミングに慣れている人にとっては故郷のように感じられるでしょう。ネストが深くなっていないので認知的な複雑さは低く、説明や理解もしやすいと言われています。

ですがKotlinではチェック例外をサポートしていません。それゆえに残念ながらこのアプローチを使う開発者はすべての依存関係を確認した上で手動で例外をチェックすることを余儀なくされます。

例外と再スロー LOC = 36, 認知的複雑度 = 9 循環的複雑度 = 13.

ロジック制御のために例外を使用することは、必ずしもKotlinの推奨するベストプラクティスに合致しない場合があることに留意する必要があります。

3位: 早期returnありのsealed classマッチング

早期のreturnを伴うsealed classのパターンマッチは、ガード条項のような挙動を示し、最初の失敗時に戻り値を返すことでプログラムが短絡的に動きます。このやり方に慣れている開発者はJavascriptやTypescriptにも精通していることでしょう。これにより、関数型エラー処理スキームと同様の効果が効果的に得られます。このアプローチは、early returnを使用しないsealed classのアプローチと比較して、sealed classの人間工学的効果をかなり高めていることがわかります。

早期returnありのsealed classマッチング LOC = 38, 認知的複雑度 = 6 循環的複雑度 = 10.

sealed classベースのアプローチに共通する問題があることに注意する必要があります: 失敗と成功の型が混同されているのです。この特性は、プログラムの抽出と拡張を極めて冗長にするだけでなく、保守性の問題を引き起こす可能性があります。

CheckNamePolicyResult は、 checkNamePolicy(…) の成功シナリオと失敗シナリオを格納する専用のsealed classです。これは、呼び出し元によってsealed class UpdatePetDetailsResult に変換されました。エラーが発生しやすいだけでなく ドメイン境界の漏洩の原因となることもしばしばあります。これは、ドメインの分離を逃れる中間サービスクラスの宣言に現れています。これらはすべて、間接的でプログラム全体にわたる複雑さを生んで問題となる可能性があります。

2位: Arrowの either { } ビルダー

Arrowが提供する either { } ビルダーは、Either型を使ったプログラミングを容易にするチェーン操作のためのものです。このビルダーは Either<L, R> 型の合成を可能にし、複雑な制御シーケンスを単純化してくれます。— https://arrow-kt.io/learn/typed-errors/either-and-ior/

Arrow 1.2.0-RCでは either { }option { } nullable { } など様々なビルダーが用意されています。これらはすべて Raise<E> と呼ばれるシンプルなコンテキスト汎用型の上に構築された抽象化です。 Raise<E> のコンテキスト内で開発者は raise(err)bind()secure(…) などの様々な拡張関数にアクセスすることができるようになります。

  • fun raise(err: L): Nothing は単に Either.Left<L> で計算を短絡させるだけです
  • fun <R> Either<L, R>.bind(): REither.Right<R> ならば R を返し、そうでなければ Either.Left<L> で短絡させます.
  • fun ensure(condition: Boolean, ifFalse: () -> L): Unitpredicate がtrueならば継続し、そうでなければ ifFalse を評価し Either.Left<L> で短絡させます

短絡イベントは、特別な軽量キャンセル例外を発生させ、スコープ内のすべてのコルーチンを確実にキャンセルすることができます。これは、 非同期処理の並列実行中におけるabort など、早期復帰が不可能な箇所でも安全に使用できることを意味します。Arrowの either { } ビルダーを使った最終的なコードは以下のようになります。

Arrowの either { } ビルダー LOC = 27, 認知的複雑度 = 3 循環的複雑度 = 4.

このアプローチの冗長性をさらに低減します。独立したエラー型を持つことの利点は、checkNamePolicy の定義側と、同じエラー境界内で動作している呼び出し元つまり either{} ビルダーの内部の両方からわかるでしょう。理論的には、このアプローチは、sealed classと早期returnと同様の認知的複雑さのスコアを生成するはずです。しかしSonarQubeではより低いスコアが報告されたようです。

1位: Arrowの context(Raise<E>) と Context Receivers

Context Receiversを利用して関数定義の上部で context(Raise<E>) を宣言することで、コンパイル時にコンテキスト型を使ってエラー境界を強制することができます。これにより結果型のエンコーディングが簡略化され、 Either<L, R> に含める必要がなくなります。これは、エラー処理に関するKotlinの推奨事項によりあったものとなります。 Either<L、R>Option<T>T? は引き続き .bind() で使用可能です。また recover を使用してエラー境界間の変換処理するためにコンパイラの支援を得ることができます。

Arrowの context(Raise<E>) と Context Receivers LOC = 27, 認知的複雑度 = 2 循環的複雑度 = 4.

context(Raise<E>) を用いたContext Receiversは、戻り値の型を Either<L, R> に含める必要がする必要がないため、認知的複雑度をさらに軽減することができます。循環的複雑度は同等で、updatePetDetails 内のコードは either { } ビルダーを使ったものとほとんど同じであることがわかります。重要な違いは、.bind() 呼び出しが不要になり、開発者は単純な型を使ってプログラムを書くことができるようになったことです。

結論

Kotlinのコミュニティにおいてはエラー処理に様々なアプローチありますが、この記事では私が個人的に調査した様々なパターンを研究してまとめた6つのエラー処理のアプローチについて検討しました。この6つのアプローチから、Kotlinが推奨するベストプラクティスに合致し、認知的な複雑さが比較的少ないパターンが3つありました: アーリーリターンを用いた密閉型クラスマッチ、Arrowの either { } ビルダー、Arrowの context(Raise<E>) Context Receiver です。6つのアプローチのうち、Arrowの context(Raise<E>) は、開発者の生産性のすべての側面で最も最適なスコアを達成しました。これには、認知的複雑度が最も低く、循環的複雑度が最も低く、コード行数が最も少なく簡潔であることが含まれます。

出典