4 ポイント 投稿者 GN⁺ 2023-08-20 | 2件のコメント | WhatsAppで共有
  • Railway Oriented Programming は、検証・ロギング・ネットワークエラーのように現実のアプリケーションで繰り返し現れる失敗経路を、関数型パイプラインの中で扱うアプローチ
  • 成功経路だけをつなぐサンプルの限界を レールウェイの比喩 で説明し、F# ユーザーでもエラーの流れを視覚的に理解しやすくしたことが核心
  • Haskell の観点では Either 型と Kleisli 合成 に近いが、抽象理論よりもエラー処理のレシピと実装パターンに焦点を当てる
  • 実際の適用に向けて、NuGet 向け F# ライブラリ Chessie、C#・F# 比較の GitHub サンプル、FizzBuzz への適用例があわせて提供される
  • 有用な手法ではあるが、あらゆる問題に過剰適用すべきではなく、F# には type class がないため、サンプルライブラリでは必要な関数を直接定義している

発表資料とサンプルコード

  • Railway Oriented Programming ページは、発表 スライドとコード をひとまとめにした資料
  • 関数型プログラミングのサンプルがしばしば前提にする「happy path」だけでは、現実のアプリケーションを作るのは難しいという問題から出発する
  • 堅牢なアプリケーションは、検証、ロギング、ネットワークエラー、サービスエラー、その他の例外的な状況をあわせて処理する必要がある

動画とスライド

  • このテーマは NDC London 2014 で発表された
  • 同じ発表の別動画も公開されている
  • Functional Programming eXchange における 2014 年 3 月 14 日の発表スライドがある
  • PowerPoint スライドは GitHub からも取得でき、自由に借用してよいと案内されている

Either モナドと Kleisli 合成

  • Haskell ユーザーには、このアプローチは Either 型として見えるかもしれない
  • ここでは Left ケースにカスタムエラー型のリストを使う形に特化している
    • 例の型は type TwoTrack a b = Either [a] (b,[a])
  • 新しく作られたのはエラー処理という概念そのものではなく、それを説明する レールウェイの比喩 に近い
  • 標準的な Haskell 用語を前面に出さない理由は明確
    • この資料は モナドのチュートリアル ではなく、エラー処理の問題解決に集中している
    • F# に触れる多くの人はモナドに慣れていないため、視覚的で威圧感の少ない説明のほうが直感的なことがある
    • bind を持つ two-track 型をすぐにモナドと呼ぶのは正確ではなく、モナド則までは扱わない
    • Either はあまりに汎用的な道具なので、ここでは道具そのものより レシピ を提供しようとしている

エラー処理レシピの構成要素

  • このアプローチは、「単に Eitherbind を使えばよい」というよりも包括的な技法の束
  • 含まれる技法は次のとおり
    • Either String a のような単純な形ではなく、左側と右側の両方で カスタムエラー型のリスト を使う
    • bind(>>=) でモナディック関数をパイプラインに統合する
    • Kleisli 合成(>=>) でモナディック関数を合成する
    • map(fmap) で非モナディック関数をパイプラインに統合する
    • F# は IO モナドを使わないため、tee で unit 関数をパイプラインに統合する
    • 例外をエラーケースにマッピングする
    • &&& で検証のような状況においてモナディック関数を並列結合する
    • ドメイン駆動設計においてカスタムエラー型がもたらす利点を活用する
    • ロギング、ドメインイベント、補償トランザクションのような拡張
  • 目標は、ほとんどあらゆる状況で使えるほど多用途でありながら、一貫したスタイルを強制できるだけの制約を持つ テンプレート を作ること
  • コードの書き方が実質的にひとつへ収束すれば、後から保守する人がコード構成を素早く理解しやすくなる
  • この方法が唯一の解決策ではないが、よい出発点にはなりうる
  • Haskell コミュニティ内でもエラー処理のやり方は一貫しておらず、初心者には混乱のもとになりうる

自分のコードに適用する方法

  • すぐ使える F# ライブラリが必要なら、NuGet と一緒に使える Chessie project を確認できる
  • この手法を適用したサンプル Web サービスは GitHub プロジェクト として提供されている
  • ROP アプローチを FizzBuzz に適用した例は Railway Oriented Programming: carbonated で見られる
  • F# には type class がないため、モナドを再利用する一般的な方法はない
  • FSharpX library は有用なアプローチを提供するが、Rop.fs ライブラリはすべての関数を最初から直接定義している
  • このように分離することで、外部依存がまったくないという利点がある

さらに読む

2件のコメント

 
GN⁺ 2023-08-20
Hacker News のコメント
  • Elixir は with キーワード/マクロでこの問題をかなりきれいに扱う
    with {:ok, file_handle} <- File.open(filename), ... do {:ok, parsed} end のように上から下へ関数を実行し、各戻り値が左側のパターンに合わなければ、その値で早期リターンする
    そのため、各関数が {:ok, value}{:error, reason} の両方を受け取るように作る必要はなく、関数には関心のある値だけを受け取らせ、with ブロックのパターンマッチにエラー伝播を処理させられる
    たとえば File.open{:error, reason} を返した場合、IO.read は実行されず、with 全体の結果が {:error, reason} になる。結局、成功経路だけを書き、失敗経路は呼び出し側が望むならマッチさせればよい

    • 「マッチしなかったものを早期リターンする」というなら、その値がすべて別の型だった場合はどうなるのか気になる。動的型付けなら、どの型かを見てどこで失敗したのか推測しなければならないのではないかと思う
      例外処理を半分ほど再発明している感じで、むしろ catch の代わりに else のような名前を付ければ、例外処理っぽく見えにくくなるだけだ
    • これは自分の知る限り、Haskell の where と Lisp の let の影響を受けた形だ
      関数の前提条件を設定するようなもので、Elixir ではさらに else ブロックも使える
    • これが例外より優れている点は何なのか気になる。それに IO.read が失敗したら、ファイルハンドルは誰が閉じるのかという問題もある
  • 著者は数年後に Against Railway Oriented Programming という続編記事も書いています: https://fsharpforfunandprofit.com/posts/against-railway-orie...
    鉄道指向プログラミングは興味深く、使いどころもありますが、大きな注意書きが必要だと思います。実際には例外処理をまずい形で再発明するために使われているケースをよく見てきましたし、例外を正しく理解して使えば、ほとんどのエラー条件はよりすっきり効果的に扱えます。
    例外の利点は、ほとんどの場合 安全な選択がデフォルト であることです。エラー条件は、コードが仕様どおりに処理できないというシグナルであり、その状態で続行すると誤った前提によってデータが壊れる可能性があります。例外は基本的にクリーンアップ後に呼び出し元へ伝播しますが、鉄道指向プログラミングのような方式では、忘れやすく間違えやすいボイラープレートを大量に要求します。
    ただし、2つの場合には有用です。1つはバリデーションのように、特定のよく定義された想定内のエラーを発生地点で処理しなければならない場合で、もう1つは例外処理を使えない環境です。昔の jQuery の Promise ベースの非同期コードは、ほとんど鉄道指向プログラミングの実装に近いものでしたが、現代の JavaScript の async/await では再び例外処理を使えます。

    • 主流言語の例外で惜しい点は、この処理が 見えない形で 起こることです。ある関数の中でどの例外が発生し得るのかをコンパイル時に把握するのは簡単ではありません。
      鉄道指向プログラミングの大きな見返りは、関数シグネチャを見るだけで、どのようなエラーが出得るのかを即座に分かることです。
      ボイラープレートは言語によって軽減できます。Haskell には do 記法があり、F# では result 計算式で非常にすっきり書けます。たとえば LoginError = InvalidUser | InvalidPwd | Unauthorized of AuthError のように定義し、Result.requireSomeResult.requireTrueResult.mapError でフローを構成できます。
      逆に、例外の欠点も軽減できるのか気になります。Java IDE と統合され、特定のコード行で投げられ得る 捕捉されていない例外 を自動的に表示するリンターや静的解析ツールがあるのか知りたいです。
      https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...
    • 従来の例外実装のやり方は、かなりいまいちだと思います。
      まず、多くの言語は例外を捕捉するために不自然で不要なスコープを強制します。例外を投げる可能性のある関数の結果として変数を宣言・初期化し、失敗時には別の値を入れたい場合でも、宣言と初期化を分けなければならない、といった具合です。
      より大きな問題は、エラー条件は正しく処理するための十分な文脈がある 最も低いレベル で扱うべきなのに、例外のデフォルトが「一番上まで投げる」ことだという点です。ファイルキャッシュを1つ追加しただけで、上位の HandleRequest 関数が突然 I/O 例外を投げ得るようになる、といった形で抽象化が漏れやすくなります。
      関数が返し得るものはすべて明示的にシグネチャの一部であるべきで、呼び出し側は明示的に処理するか、上へ渡すことを示すべきだと思います。言語がそのために大量のボイラープレートを要求する必要はありません。
    • Rust は、第一級の構文サポートを持つ鉄道指向プログラミングが ボイラープレートをほぼなくせる ことをよく示したと思います。個人的には、Rust の Result 型/トレイトはエラー処理をきちんとやり遂げています。
    • 値としてのエラーと例外の違いは、ドメイン固有のエラー を説明するときに最も有用だと思います。ドメインエラーは値として表現するのが適切で、データベースに接続できないといったドメインと無関係な問題は、例外のほうが自然です。
      ファイルやレコードが存在しない場合も、それが想定内かどうかによります。ユーザーが与えた ID で検索するリクエストなら、その検索自体がユーザー入力の検証です。しかし、データベースレコードに保存された blob 名でストレージを検索したのに blob がないなら、それは例外として扱うのに向いた状況です。
      エラーと例外の境界は曖昧で、チームや問題領域によって変わりますが、このくらいが妥当な経験則だと思います。
    • システムレベルのエラーを除くと、「発生地点で処理すべき具体的でよく定義された想定内のエラー」ではないエラー状態の例を知りたいです。そのような場合に例外のほうがよい理由が、問題の性質によるものなのか、ランタイム上の制約によるものなのかを理解したいです。
      C++ コードでは、最初にデータを検査し、検査に失敗したらアプリケーションをクラッシュさせ、見つかったエラー状態に基づいて初期検査コードをデバッグ・修正する、という形で処理できます。
      扱っているデータは、既知の CAD データ形式 や幾何トポロジーに従う比較的明確なデータなので、正しいデータとは何かを先に理解できるという点で、エラー条件の扱いは「かなり簡単」な部類です。
  • 正直、このサイト全体が宝の山です。関数型言語を使うつもりがなくても別の視点を与えてくれますし、それが個人的に大きな助けになりました。他の記事もおすすめです。

    • 最初から パーサーコンビネータ を作るシリーズは、読んで手を動かしたものの中で最も価値のある資料でした。そこで出てきた多くの概念や仕組みは、型付き言語を関数型スタイルで扱うときに非常に役立ちました。
      それでもモナドが何なのかは、結局分からないままだと思います。
      https://fsharpforfunandprofit.com/series/understanding-parse...
  • 「エラー処理を呼び出し地点から遠ざけるのは悪い」という主張が成り立つのか気になる。呼び出し側がエラーを最も適切に処理できるのだから、下に渡すより自分で処理すべきではないかと思う
    たとえば validate and-then update-db and-then send-email という流れで、validate の失敗は呼び出し側に返してもよいかもしれない。しかし update-db が失敗した場合は、再試行するのか、別のサービスを使うのか、キューに戻すのか、ユーザーに知らせるのかを決める必要がある。send-email の失敗も同様
    結局、ValidationError はユーザーに知らせ、DBError は5分後に再試行するかリモートで再試行してから知らせ、EmailFailed はメールを再びキューに入れて成功とみなす、というような、より複雑な分岐構造になるのではないかと思う。もしかすると、それでよいのかもしれない

    • モナド的なエラー処理は、双方の長所を得るやり方
      Goが嫌いな人は、エラー条件をあまりに頻繁に確認しなければならないと不満を言うし、Javaのチェック例外も似ている。Either を使えば確認を強制されず、自分で確認することも呼び出し側に渡すこともできる
      Javaの非チェック例外が嫌いな人は、何がいつ投げられるのか分からないと不満を言う。Either を使えばそれが明示される
      核心は、エラーが起きたときに何をすべきか分からないという点で、Either はその「分からなさ」によく合っている。現在のJavaコードベースでは、Limit getUserLimit(User) が存在しないときに NotFound 例外を投げるかもしれないし、null を返すかもしれないし、デフォルト値を返すかもしれないが、コードを掘り下げるまでは分からない
      Either だったなら、呼び出し側は内部コードを読まなくても信頼でき、orElse(null)orElseThrow(...) のような組み込みツールで別の失敗条件へ簡単に変えられる。また Either は式なので抽象化しやすく、DbUpdater の内部に特化した再試行コードではなく、汎用的な再試行ロジックを書ける
    • この論理をJavaの try-catch スタイルで実装してみたが、ネストした trycatch(DBError)sleep(5)、リモート再試行、EmailFailedError の再キューイング、ValidationError の通知が絡み合って、はるかに悪く見える
      https://gist.github.com/Andrewp2/9d97bd213b061166d6df565ce26...
      誰かがもっと良い try-catch 版を書けるかもしれないが、提示されている鉄道指向版よりずっと劣ると思う
    • 低レベルと高レベルのエラー処理は共存できるが、このスタイルはホワイトボード上の例や、一度作って終わりの状況により向いていると思う
      DDDは好きだが、私のような普通のプログラマーが管理できる、合理的な保守サイクルが必要だ。変更可能なコードでTDDが良かったのは、保守の認知負荷を減らしてくれたから
      鉄道指向プログラミングは、多くの変換を経ながら多くの文脈を運べるので、線路の終点で、準備できていないビジネス・システム文脈を山ほど積んだ列車を受け取る状況は避けたい
      DDDとオニオンアーキテクチャは、両方の利点を与えてくれると思う。結局、鉄道はできるが、マイクロアプリの内部に埋め込まれるのではなく外側にある。鉄道指向の問題は、明示的で少し面倒にコーディングされるほうがよく、うまくやれているなら、鉄道指向の判断の大半は、賢いコーディング構造で避けるべきではないビジネス上の判断である
      モナド的プログラミングは素晴らしいが、必要なビジネス判断を避けるために使いたくなる誘惑がある。曖昧さを型システムに入れると、後で困難または不可能な状況につながりかねない
    • 低レベルと高レベルのエラー処理は共存できる。大きな try/catch の中に特化した try/catch があるのと変わらない
      たとえば send-email 関数の内部で、非常に短時間の一時的エラーを処理するよう選択できる。許容時間内に接続できれば成功パスに戻り、そうでなければ呼び出し側に返せばよい
      確かなルールがあるとすれば、人の介入が必要な処理は必ずラップして上に渡すべきだということ。成功パスに戻るとしても、警告ログは残しておくほうがよい
    • 挙げた例のように終わることもあり得るが、同じ基本的な最上位パイプラインを保ちながら、別の構成にもできる
      notify-userValidationErrorDBError ごとのユーザー通知を担当し、try-update-dbupdate-db、5分後の再試行、リモート再試行をまとめ、try-send-email は失敗時にメールを再びキューに入れて成功に変えられる
      そうすれば最上位は validate and-then try-update-db and-then try-send-email or-then notify-user のように保てる。and/or/thenmap/bind にうまく対応させているように思う
  • このサイトは、実際的で実用的な概念を教えているという点で、これまで出会ったプログラミング教育サイトの中で最高だった。特に不正な状態を表現不可能にするという概念が気に入っていて、言語に関係なく適用しようとしている。もちろん、より簡単にしてくれる言語もある
    https://fsharpforfunandprofit.com/posts/designing-with-types...

  • 呼び出し/戻り値の代わりにデータフローを使うと、この問題はよりうまく解決でき、かなりの部分が自然に消える
    呼び出し/戻り値では何かを返さなければならないため、エラーのせいで返す値がない場合はエラーを返すか、Go のようにエラーと正常な戻り値を一緒に返さなければならない。これが成功パスを汚染する。多相的なコンテナで残りの処理をつないで渡せば少しはよくなるが、問題は残る
    データフローでは次のフィルター段階に何も送らなければよいので、成功パスはまったく影響を受けない。エラーは標準エラー出力のような場所に送り、アプリケーションレベルで集中管理できる
    良すぎる話に見えるかもしれないが、Wunderlist では実際に使っていて、うまく動いていた。値がないときに使う同じ手法は、まだ値がないとき、つまり非同期処理にもそのまま適用できる

    • これがデータを返すことや、別の場所で捕捉されるように例外を投げることと根本的に違うのか気になる
    • 理解を助けるコード例があるとよさそう
    • その方式にも欠点があり、特に制約が強いと感じる。例外処理、とりわけ非同期例外処理はエラーからの復旧を難しくすることがある
      単純なデータストリームでエラーが起きた場合、次の段階にデータがないので止まる、というのは問題ない。しかし sqrt(-1) で即座に「クラッシュ」してエラー出力やロギングへ飛んでしまうと、特定のユースケースで負数に対して別の値を与えるようにドメインを広げたいときに難しくなる
      このとき Maybe のような明示的なエラー型が役に立つ。成功パスはエラーがないかのように書きつつ、関数合成には別の演算子を使う。エラーから復旧したければその場で両方のパスを処理し、その後はエラーを許容しないこともできる。抽象的な「存在するかもしれない値」から実際の値を取り出すには、両方のパスを優雅に扱わなければならない点が特に良い
      明示的なエラー型を使えば、成功/エラーという二分法からも抜け出せる。「値はないが」「値はあるが」といった状態も表現でき、並列に処理できる複数のタスクでエラーを蓄積して結果として集めることもできる。さらに非決定性をエンコードして、グラフ走査のような用途にも使える
      Haskell プログラマーがするように状態を純粋な方法で運ぶこともできるし、悪名高い Haskell の IO モナドは、実際のパスを表すというより、外部世界と接触する地点を main に制限する仕組みに近い。純粋性のためではあるが、純粋性が常に絶対的に追求すべき価値だとは思わない
      この手法は単純な鉄道よりはるかに多様で、純粋性を保ちながら成功パスだけを書くためのすっきりした方法を提供する。方程式ベースの静的解析や、モックなしのユニットテストにも有利だ。より多くの主流言語が、こうしたモナド道具箱をもっときちんと提供してくれるとよいと思う
  • 関連リンク: Railway Oriented Programming - https://news.ycombinator.com/item?id=31404643 - 2022年5月、Railway-Oriented Programming (2015) - https://news.ycombinator.com/item?id=17337155 - 2018年6月、Railway oriented programming - https://news.ycombinator.com/item?id=11955917 - 2016年6月、Railway Oriented Programming - https://news.ycombinator.com/item?id=9166943 - 2015年3月、Railway-oriented programming - https://news.ycombinator.com/item?id=7887134 - 2014年6月
    追加で What is railway oriented programming? (2020) - https://news.ycombinator.com/item?id=34245639 - 2023年1月、Railway Oriented Programming in Elixir (2015) - https://news.ycombinator.com/item?id=11958578 - 2016年6月

  • このスタイルは他のモナドと同じように感染性があるので、すべてのビジネスロジックがこの形になる。それで構わないなら読み進めればよい
    例外はその名の通り予期しないエラーであり、捕捉される場所までスタックを巻き戻しながら、必要な後片付けは自動的に処理される。I/O エラーやメモリ不足のように、ビジネス関数の範囲外で発生するエラーに適している
    データを検証したいなら、例外ハンドラやモナドに頼る必要はない。関数型パイプラインの結果をデータにすればよい。検証エラーがあれば集合として集め、パイプラインが終わった後で確認すればよい。通常は最初のエラーで失敗させたくない可能性が高い
    パイプラインで副作用が必要なら、それを行わず、その効果をデータとして記述して後で実行すればよい。途中で早期に抜ける必要があるなら、その地点でパイプラインを分割し、結果を処理してから別のパイプラインに入れればよい
    あるいはインターセプターチェーンを使って、成功パスを続けるかどうかを決めさせることもできる。言語が対応していればパターンマッチングを使えばよい。重要なのは、パイプラインの一部をスキップするかどうかの判断がビジネス関数の外側で行われる点であり、そうすればビジネス関数は純粋なデータ変換だけに集中するため、再利用性が高くなる

  • こういう話題がまた取り上げられて議論されるだけでもうれしい。このサイトを1年間読み、例を追って試してきたが、最高の F# 資料の一つだった
    このサイトで扱われている概念だけでも、どの言語でも関数型的に考える助けになった
    古いサイトなのに今注目されているのだとしたら、F# が勢いを得ているということなのか気になる。それに、ほかの言語のプログラマーも Rust のように鉄道式のエラー処理を使っているのかも気になる

    • デスクトップアプリケーション向けの C# のバックグラウンドが強く、時間がたつにつれて C# のコーディングスタイルがより関数型寄りになっている。Linq を多用し、不変クラスや詳細なエラー情報を持てる戻り値型を使う、といった具合だ
      それでも F# に移れない理由は、ほとんどの時間をデスクトップのフロントエンド調整に費やしているからだ。この領域では F# があまり利点をもたらさないように思う。本番投入できるデスクトップフロントエンドはネイティブ F# ではなく、ほとんどが C# ベースのオブジェクト指向コードで、F# とは薄いグルーコードで結び付けられる
      少しでも複雑な UI カスタマイズが必要になると、またオブジェクト指向の世界に戻らなければならず、それなら最初から C# でやる方がいいと思っている。個人的には、F# が勢いを得るには ネイティブ UI フレームワークが必要だ
    • C++ でもそういうやり方を使っている。戻り値型 T と結果条件を定義する特定の列挙型でパラメータ化できる テンプレート戻り値型を定義する
      急ぐなら std::pair でもできるし、エラーメッセージを文字列に入れて返し、その文字列が空でなければ早期終了する方法も可能だ。すべての関数がそうした型を返せば、エラーメッセージを最上位までバブルアップできる
  • この記事が数年ごとにまた上がってくるのはいい。本当に素晴らしい記事であり発表だ
    読み返すたびに、自分の技術的な歩みの中でスキルや哲学の面で違う位置にいるので、毎回違って見えるところも興味深い

    • こういう資料は本当に良い。Python の発表で “so you want to be a python expert” というものを、初期の数年間は半年ごとに見ていたが、見るたびに理解できることが増えていった。今また見ても、おそらく新しく得るものがありそうだ