The best days are ahead of us.

鉄道指向プログラミング(の安易な利用)に反対する

2025-03-10 13:00:25

(これは、Against Railway-Oriented Programming - F# for Fun and Profitの日本語訳です)

この記事は、2019年のF#アドベントカレンダーの一部です。他の素晴らしい記事もぜひご覧ください。企画者のSergey Tihon氏に感謝します。

6年半前、私は「鉄道指向プログラミング」と呼ぶものに関する記事を書き、講演を行いました。これは、エラーを生成する関数を連結するために、Result / Either をどのように使用するかを、自分自身や他の人に説明するための手段でした。

驚いたことに、このささやかな鉄道のアナロジーは瞬く間に広まり、今ではRubyJavaJavaScriptKotlinPythonなど、さまざまな言語に対応した鉄道指向プログラミングのライブラリや記事が存在します。

私は今でもこれが良いアナロジーだと思っています。しかし、特にツールボックスに追加したばかりの目新しい手法である場合は、安易に使われがちであるとも思っています。

そこで、この記事では、鉄道指向プログラミングを使用すべきでない理由を説明します。より正確に言えば、(ROPはResultを返す関数を接続するために使用される単なる配管であるため)Result型をどこでも使用すべきでない理由を説明します。エラー管理に関するMicrosoftのページこちらのブログ記事も参考になります。

#1 - 診断が必要な場合はResultを使用しない

エラーの場所、スタックトレース、その他の診断情報を重視する場合は、Resultを使用しないでください。特に、例外の代わりにResultを使用し、スタックトレースまたは例外全体をResult内に保存しないでください。意味がありません。

代わりに、Resultを追加情報付きのブール値と考えてください。予期しない状況ではなく、予期される制御フローにのみ使用します。

#2 - 例外を再発明するためにResultを使用しない

例外で処理した方が適切なものを含め、あらゆる種類のエラー処理にResultを手当たり次第に使用する人がいます。「try-catch」を再発明しないでください。

また、例外を完全に隠そうとする人もいます。これは無駄な試みです。どれほど多くの例外をResultに変換しても、いくつかは常に漏れ出してしまいます。システムの最上位では、常に例外を適切に処理する必要があります。

#3 - 迅速な失敗が必要な場合はResultを使用しない

何か問題が発生し、続行できない場合は、Resultを返して続行しないでください。代わりに、例外またはアプリを直ちに終了するなどして、迅速に失敗してください。

#4 - 誰も見ない場合はResultを使用しない

複雑な制御フローを実行しているが、そのロジックが外部から隠されている場合は、単にそのためにResultを使用しないでください。多くの場合、ローカルで例外を使用する方がクリーンです。

たとえば、ツリーをトラバースして情報を収集し、何か問題が発生した場合に早期に終了する必要があるとします。

ROPアプローチでは、ノード処理関数がResultを返し、それをbindを使用して次のノード処理関数に渡す必要があります。複雑なナビゲーションの場合、コードがコンパイルされるようにロジックを解決するのに多くの時間を費やす可能性があります(もちろん、Haskellプログラマーは例外です)。

一方、プライベートなローカル例外を定義し(たとえば、PythonのStopIterationのようなスタイルで)、反復を命令的に記述し、早期に戻る必要がある場合に例外をスローし、最上位で例外をキャッチすることができます。コードがあまり長くなく、例外がローカルで定義されている限り、このアプローチは多くの場合、コードをより明確にすることができます。また、コンシューマーが内部構造を見ることがない場合は、問題ありません。

別の例としては、マイクロサービスを定義する場合が挙げられます。コード全体が数百行程度で、呼び出し元に対して不透明な場合は、例外がサービス境界から漏れない限り、Resultではなく例外を使用してもまったく問題ありません。

#5 - エラーケースを誰も気にしない場合はResultを使用しない

通常、Resultは、発生する可能性のあるすべての問題の判別共用体であるエラーケースで定義されます。

たとえば、ファイルからテキストを読み取りたいとします。次のような関数を定義します。

type ReadTextFromFile = FileInfo -> Result<string, FileError> 

ここで、FileErrorは次のように定義されます。

type FileError = | FileNotFound | DirectoryNotFound | FileNotAccessible | PathTooLong | OtherIOError of string 

しかし、この関数のコンシューマーは、ファイルの読み取りで発生する可能性のあるすべての問題を本当に気にするでしょうか?おそらく、彼らは単にテキストを求めており、それが機能しなかった理由を気にしないでしょう。その場合、次のようにoptionを返す方が簡単かもしれません。

type ReadTextFromFile = FileInfo -> string option 

Resultはドメインモデリングのツールであるため、ドメインモデルがそれを必要としない場合は、使用しないでください。

同様の例は、イベントソーシングを実装する場合、標準的なシグネチャを持つコマンド処理関数に見られます。

'state -> 'command -> 'event list 

コマンドの実行中に何か問題が発生した場合、実際には戻り値(コマンドによって作成されたイベントのリスト)にどのように影響しますか?もちろん、エラーを処理してログに記録する必要がありますが、関数自体からResultを返す必要があるでしょうか?メリットがあまりないのに、コードがより複雑になります。

#6 - I/OエラーにResultを使用する場合は注意する

ファイルを開こうとしたときにエラーが発生した場合、それをResultでラップする必要がありますか?それはあなたのドメインによります。ワードプロセッサを作成している場合、ファイルを開けないことは予期されることであり、適切に処理する必要があります。一方、アプリが依存する構成ファイルを開けない場合は、Resultを返すのではなく、すぐに失敗する必要があります。

I/Oがある場所では、非常に多くの問題が発生する可能性があります。すべての可能性をResultでモデル化しようとするのは魅力的ですが、強くお勧めしません。代わりに、ドメインに必要な最小限のものだけをモデル化し、他のすべてのエラーを例外にしてください。

もちろん、ベストプラクティスに従ってI/Oを純粋なビジネスロジックから分離すれば、コアコードで例外を処理する必要はほとんどありません。

#7 - パフォーマンスを重視する場合はResultを使用しない

これは絶対的な禁止というよりも「注意する」という程度です。パフォーマンスが重要なコードセクションがあることを事前に知っている場合は、そこでResultを使用することに注意してください。実際、他の組み込み型(たとえば、List)にも注意する必要があります。しかし、常に事前に推測するのではなく、ホットスポットを見つけるために測定し、間違ったものを過剰に最適化しないようにしてください。

#8 - 相互運用性を重視する場合はResultを使用しない

ほとんどのOO言語は、Resultやその他の判別共用体を理解しません。APIから起こりうる失敗を返す必要がある場合は、呼び出し元にとってより慣用的なアプローチを使用することを検討してください。場合によっては、nullを使用することもあります。呼び出し元はあなたのAPIを呼び出したいだけなのに、関数型イディオムの専門知識を押し付けないでください。

Resultを使用しない理由のまとめ

  • 診断: スタックトレースやエラーの場所を重視する場合は、Resultを使用しないでください。
  • try/catchの再発明: 既に組み込まれている言語ツールを使用してみませんか?
  • 迅速な失敗: ワークフローの最後にいずれにせよ例外がスローされる場合は、ワークフロー内でResultを使用しないでください。
  • 制御フローのためのローカル例外はOK: 制御フローが複雑でプライベートな場合は、制御フローに例外を使用してもOKです。
  • 無関心: エラーを誰も気にしない場合は、Resultを返さないでください。
  • I/O: あらゆる可能性のあるI/OエラーをResultでモデル化しようとしないでください。
  • パフォーマンス: パフォーマンスを重視する場合は、Resultの使用に注意してください。
  • 相互運用性: 相互運用性を重視する場合は、呼び出し元にResultとは何か、どのように機能するかを理解することを強制しないでください。

Resultを使用すべき場合

では、これほど否定的なことを述べた後で、どのような状況でResultを使用すべきなのでしょうか?

私の著書『Domain Modeling Made Functional』で述べたように、私はエラーを3つのクラスに分類するのが好きです。

  • ドメインエラー: これらはビジネスプロセスの一部として予期されるエラーであり、したがってドメインの設計に含める必要があります。たとえば、請求によって拒否された注文や、無効な製品コードを含む注文などです。ビジネスはすでにこのような事態に対処するための手順を用意しているため、コードはこれらのプロセスを反映する必要があります。ドメインエラーは他のものと同様にドメインの一部であるため、ドメインモデリングに組み込み、ドメインエキスパートと話し合い、可能であれば型システムに文書化する必要があります。診断は必要ないことに注意してください。Resultを単なる高機能なboolとして使用しています。
  • パニック: これらは、ハンドルできないシステムエラー(例:「メモリ不足」)やプログラマーの過失によるエラー(例:「ゼロ除算」、「null参照」)など、システムを未知の状態にするエラーです。パニックは、ワークフローを放棄し、最高位の適切なレベル(例:アプリケーションのメイン関数または同等のもの)でキャッチおよびログに記録される例外を発生させることによって処理するのが最適です。
  • インフラストラクチャエラー: これらはアーキテクチャの一部として予期されるエラーですが、ビジネスプロセスの一部ではなく、ドメインに含まれません。たとえば、ネットワークタイムアウトや認証失敗などです。これらの処理をドメインの一部としてモデル化する必要がある場合もあれば、パニックとして扱うことができる場合もあります。不明な場合は、ドメインエキスパートに相談してください。

上記の定義を使用すると、次のようになります。

  • Resultは、予期される戻り値を文書化するために、ドメインモデリングプロセスの一部としてのみ使用する必要があります。そして、コンパイル時に、考えられるすべての「予期される」エラーケースを処理することを確認します。
  • ライブラリなどのマイクロドメインも、適切であればResultを使用できます。

まとめると、Result型と鉄道指向プログラミングは、適切に使用すれば非常に役立つと思いますが、ユースケースはあなたが考えるよりも限定的であり、クールで面白いからといってどこでも使用すべきではありません。

お読みいただきありがとうございます!F#に関する投稿をもっと読みたい場合は、2019 F# Advent Calendarの他の投稿もチェックしてみてください。