Clojure は S式で動的型付けな JVM 上の(非純粋)関数型プログラミング言語です。
この性質から、Clojure ではしばしばエラーハンドリングや、処理の実行フローについて議論が起こります。
Clojure の処理の実行フローについて用いてきたパターンは多く存在しますが、特に有力な (?) パターンに
- 鉄道指向プログラミング (Railway Oriented Programming) を応用したパターン
- try-catch パターン
があります。
※ 他にも強制力の強いライブラリを利用する手段もありますが、一旦ライブラリについては議題から追い出して話を進めます。
Table of Contents
鉄道指向プログラミングを応用したパターン
鉄道指向プログラミングとは、F# で有名だったプログラミングスタイルです。
これはしばしば Haskel の Either Monad と同一視されることもあるようですが、両者の出自が同じ、という話はちょっと聞かないです。
2021 現在では Scala や Swift でギリギリ生き残っているのかな、というところ。
鉄道指向プログラミングでは、関数の返り値に正常系の結果 result
とそれ以外のパターン err
のタプル [result, err]
を想定し、実行フローの各プロセスで得られる err
が nil
でない時 Early-Return をかける方式です。
[process A] --- [process B] --- [process C] --> correct
| |
-------------`---------------`----------------> error (early-return)
ここで一旦鉄道指向プログラミングのことは忘れてあるサーバのユーザデータ更新処理のことを考えます。
Clojure では標準で Threading Macro と呼ばれる、あるデータに対して連続して関数を適用するパターンの美しい記述方法があり、次のように記述できます。
(->
{:name "MeguruMokke"
:email "meguru.mokke@gmail.com"
:password "xxx"}
interface.controller.update-user/http-> ;; => http request から作った User モデル
usecase.update-user/find-user ;; => User モデル
usecase.update-user/update-user! ;; => User モデル
interface.presenter.update-user/->http ;; => http レスポンス
)
しかし、上の例では各関数で起きる可能性があるエラー処理を記述できていません。例えば、ユーザからのリクエストが仕様を満たしていない時、ユーザがすでに存在している時、DBに接続できなかった時などが挙げられます。
ここでおもむろにすべての関数の返り値を [result, err]
とし、 err が存在する時、続きの関数処理を行わない err->>
マクロを定義します
(defn bind-error [f [val err]]
(if (nil? err)
(f val)
[nil err]))
(defmacro err->> [val & fns]
(let [fns (for [f fns] `(bind-error ~f))]
`(->> [~val nil]
~@fns)))
すると、上の例は次のようにエラーハンドリングを追加することができます。これが Clojure における鉄道指向プログラミングを利用したプログラムの書き方になります。
(err->>
{:name "MeguruMokke"
:email "meguru.mokke@gmail.com"
:password "xxx"}
interface.controller.update-user/http-> ;; => [http request から作った User モデル, err]
usecase.update-user/find-user ;; => [User モデル, err]
usecase.update-user/update-user! ;; => [User モデル, err]
interface.presenter.update-user/->http ;; => [http レスポンス, err]
)
この手法の利点は、既存の Threading Macro とほとんど同じくプロセスの流れを記述できることです。
問題点を上げるとするならば、それはレールの上へ乗せるすべての関数について [result, err]
のパターンの実装が強制されることです。(もちろん err->>
マクロをの拡張で対応もできますが、対応パターンを増やせばそれだけ仕様が複雑になります)
try-catch パターン
この手法は古き(良き) Java の実装パターンになります。
関数について異常系のケースがあれば、例外オブジェクトをスローし、呼び出し元で catch し適切な処理に分岐させます。
(try
(usecase.update-user/find-user user)
(catch ExceptionA e
(user-not-found-solver e))
(catch ExceptionB e
(db-error e)))
この手法はオブジェクト指向に慣れ親しんだ方であれば鉄道指向プログラミングよりもしっくり来ると思います。
ただし本手法はいくつか問題があります。
その中の最大の原因の一つは Clojure 自体の問題です。
Clojure は Java の throws のような、関数についての Throwable な例外を記述することが困難です。
そのため、実装中に例外処理をすべて拾うことが難しく、よって Clojure においては、例外処理で(特に処理しなければならない)異常系を記述するのはおすすめできません。
結局どうすればええん
私も今絶賛研究中です。
世論的な話をすると、鉄道指向プログラミングの提案者は数年前に、この技法についての注意点を発表しており、鉄道指向プログラミングの使いどころを以下のようなチェックリストで示しています。要するに、用法用量を守って使ってください、とのことです。
- [使うべきではない] StackTrace が必要なパターン
- [使うべきではない] 例外処理で十分対応できる時
- [使うべきではない] Early Return させる時
- [使うべきではない] 内部ロジックでその例外を隠蔽できる時
- [使うべきではない] その例外を上位で受け取って別の処理をする見込みがない時
- [使うべきではない] 外部ライブラリとして公開する場合
- [使うべき] ビジネスプロセスの一部である時
実際の書籍 Domain Modeling Made Functional においても、それらは具体的な例として出されており、例えば商品購入の際のデータバリデーションなどには [result, err]
パターンが用いられています。
この流れに対して
- Clojure を始めとする関数型言語では、データは入力と出力がコンスタントに決まるような純粋な関数を書きたがることから、
input -> [result, err]
パターンは捨てがたいことがある (I/O モナドっぽくて純粋に見える) - 先述の
err->>
マクロが非常に使い勝手が良く、可読性が高い
ことから、できる限り鉄道指向プログラミングの 形式 はそのまま使いたいところ、と思っています。
個人的な思想としては、今までは鉄道指向プログラミングをメインに扱っていた背景と、最近プロジェクト周りでスタックトレースとログについてその重要性を痛感することが増えていることから、鉄道指向プログラミングをする中で [result, err]
の err
を必要に応じて 例外オブジェクトにする書き方を考えています。
Q & A
Q: スタックトレースを作るのがコストかかるらしいし、try-catch パターンはやめたほうが良いのでは?
A: try-catch 文自体にコストがかかるという噂もありますが、そこまで複雑な実装にしていない && パフォーマンスを求めない限りは実装に用いてもそこまで問題はないです。
おそらく上記の噂は、例外オブジェクトを catch するたびに作り直すような実装をしているコストを評価しているのであって、これに対して例外をたらい回しするのは比較的に問題ないです。
ref:
-
最近のエラー処理についての議論スレッド
https://clojureverse.org/t/how-are-clojurians-handling-control-flow-on-their-projects/7868/3 -
鉄道指向プログラミングを利用した clojure のエラー処理
https://adambard.com/blog/acceptable-error-handling-in-clojure/ -
Swift における鉄道指向プログラミングパターン
https://qiita.com/shiz/items/d253d44e97e0374eb5d9#swift%E3%81%AEresult%E3%81%A8%E3%81%AF -
鉄道指向プログラミングに対する反論 (提案者自身によるもの)
https://fsharpforfunandprofit.com/posts/against-railway-oriented-programming/ -
Java における例外処理のコストについての話
https://stackoverflow.com/questions/36343209/which-part-of-throwing-an-exception-is-expensive -
Unit test と 例外処理
https://stackoverflow.com/questions/32063244/clojure-unit-testing-how-do-i-test-if-a-function-throws-an-exception