F# におけるフリーモナドの組み合わせ
2025-04-13 21:00:25
原文: Combining free monads in F# by Mark Seemann
F#でフリーモナドを合成する例
この記事は、純粋な関数型コードを使って、長時間にわたるインタラクションをモデル化する方法について解説する連載記事のひとつです。前回の記事では、純粋なコマンドラインAPIとHTTPクライアントAPIをHaskellで組み合わせる方法を説明しました。この記事では、そのHaskellによる概念実証をF#に移植する方法を見ていきましょう。
HTTP APIクライアントモジュール
以前の記事で、コマンドラインのインタラクションを純粋なコードとしてモデル化する方法はすでにご覧いただきました。オンラインのレストラン予約HTTP APIとのやり取りも、同じようにモデル化できます。まず、APIの入出力に必要な型をいくつか定義します。
type Slot = { Date : DateTimeOffset; SeatsLeft : int }
type Reservation = {
Date : DateTimeOffset
Name : string
Email : string
Quantity : int }
Slot型は、特定の日付に利用可能な空席数についての情報を持ちます。Reservation型は、予約に必要な情報を持つ型です。これは以前の記事で紹介したReservation F#レコード型と同じものですが、今回はこちらに移動しました。
オンラインレストラン予約のHTTP APIは、必要以上の機能を提供しているかもしれませんが、実際に必要な命令だけをモデル化すれば十分です。
type ReservationsApiInstruction<'a> =
| GetSlots of (DateTimeOffset * (Slot list -> 'a))
| PostReservation of Reservation * 'a
この命令セットは、2つのインタラクションをモデル化します。GetSlotsケースは、特定の日付の空き状況をHTTP APIに問い合わせる命令を表します。PostReservationケースは、Reservationデータを使ってPOSTリクエストを送り、予約を実行する命令を表します。
Haskellならこの型を自動でFunctorにできますが、F#の場合は自分でコードを書く必要があります。
// ('a -> 'b) -> ReservationsApiInstruction<'a>
// -> ReservationsApiInstruction<'b>
let private mapI f = function
| GetSlots (x, next) -> GetSlots (x, next >> f)
| PostReservation (x, next) -> PostReservation (x, next |> f)
これでReservationsApiInstruction<'a>はファンクターになりますが、これが最終的なゴールではありません。最終目標は、糖衣構文(シンタックスシュガー)を使って、標準的なF#の構文で純粋なReservationsApiInstruction<'a>の抽象構文木(AST)を書けるようにすることです。これを実現するにはコンピュテーション式ビルダーが必要で、そのビルダーを作るにはモナドが必要になります。
以前にも見たフリーモナドのレシピを使えば、ReservationsApiInstruction<'a>をモナドに変換できます。ただし、フリーモナドを作るには、モナドかつファンクターとなる別の型を追加することになるため、混乱を避けるためにmapIはあえてプライベートにしています。これが、この関数にmapという名前を付けなかった理由でもあります。mapという名前は、別の型で必要になるからです。mapIのIはinstruction(命令)の頭文字です。
mapI関数は、(暗黙的に渡される)ReservationsApiInstruction引数に対してパターンマッチを行います。GetSlotsケースでは、新しいGetSlots値を返しますが、その際にはnextという継続関数をfと合成します。PostReservationケースでは、新しいPostReservation値を返しますが、nextをfにパイプ処理します。このように処理が異なるのは、PostReservationが特殊なケースだからです。つまり、nextは関数ではなく、単なる値なのです。
ReservationsApiInstruction<'a>がファンクターになったので、これをもとにフリーモナドを作ることができます。最初のステップとして、モナドを表す新しい型を導入しましょう。
type ReservationsApiProgram<'a> =
| Free of ReservationsApiInstruction<ReservationsApiProgram<'a>>
| Pure of 'a
これは再帰的な型定義で、最終的に値を返すASTを組み立てられるようになります。Pureケースは値を返すために使い、Freeケースは次に実行すべき処理を記述するために使います。
mapIを使うことで、bind関数を追加してReservationsApiProgram<'a>をモナドにできます。
// ('a -> ReservationsApiProgram<'b>) -> ReservationsApiProgram<'a>
// -> ReservationsApiProgram<'b>
let rec bind f = function
| Free instruction -> instruction |> mapI (bind f) |> Free
| Pure x -> f x
CommandLineProgram<'a>のbind実装を見返してみると、コードがまったく同じであることに気づくでしょう。Haskellではファンクターからフリーモナドを作るのは自動ですが、F#では定型的なコード(ボイラープレート)を書く必要があります。
同様に、ReservationsApiProgram<'a>をファンクターにすることもできます。
// ('a -> 'b) -> ReservationsApiProgram<'a> -> ReservationsApiProgram<'b>
let map f = bind (f >> Pure)
これもCommandLineモジュールのコードと同じです。コピー&ペーストで使い回せます。ただし、扱っている型が違うため、機能としては別物です。
最後に、予約用HTTPクライアントAPIを仕上げるために、命令(instruction)をプログラム(program)に「持ち上げる(lift)」ための関数を用意しましょう。
// DateTimeOffset -> ReservationsApiProgram<Slot list>
let getSlots date = Free (GetSlots (date, Pure))
// Reservation -> ReservationsApiProgram<unit>
let postReservation r = Free (PostReservation (r, Pure ()))
これで、小さなコンピュテーション式ビルダーを作るのに必要なものがすべて揃いました。
type ReservationsApiBuilder () =
member this.Bind (x, f) = ReservationsApi.bind f x
member this.Return x = Pure x
member this.ReturnFrom x = x
member this.Zero () = Pure ()
reservationsApiコンピュテーション式を使うには、ReservationsApiBuilderクラスのインスタンスを作成します。
let reservationsApi = ReservationsApiBuilder ()
以上で、オンラインのレストラン予約システムとやり取りするための純粋なAPIが定義できました。これには、コードを簡潔に保つために必要な糖衣構文もすべて含まれています。いつものことですが、いくつか定型コードが必要になります。しかし、一度書いてしまえばめったに変更することはないでしょうから、メンテナンスの手間についてはそれほど心配していません。レシピに従って実装していれば、このAPIは圏論、ファンクター、モナドの法則を満たします。つまり、これは独自に考案したものではなく、普遍的な抽象化の一例なのです。
モナドスタック
ここまでのReservationsApiモジュールの追加は、最終目標である「オンラインAPIに対して予約を行うコマンドラインウィザードを書く」ための一歩に過ぎません。これを実現するには、CommandLineProgram<'a>とReservationsApiProgram<'a>という2つのモナドを組み合わせる必要があります。Haskellの場合、モナドを積み重ねられる組み込みの汎用型FreeTによって、この組み合わせは自動的に手に入ります。一方、F#では、この組み合わせのための型を明示的に宣言しなければなりません。
type CommandLineReservationsApiT<'a> =
| Run of CommandLineProgram<ReservationsApiProgram<'a>>
これは、ReservationsApiProgramとCommandLineProgramを積み重ねた、単一ケースの判別共用体です。この実装では、Runという名前のケースをひとつだけ定義しています。こうすることで、あまり深く考えずにフリーモナドのレシピに従うことができるからです。後ほど、この型を簡略化できることを示します。
この命名はHaskellからヒントを得ています。この型は、HaskellのFreeT型に対応するパズルのピースのようなものです。FreeTのTはtransformer(変換子)の頭文字です。なぜなら、FreeTは実際にはモナド変換子と呼ばれるものだからです。これはF#の文脈ではさほど重要ではありませんが、CommandLineReservationsApiT<'a>という名前にTを付けたのはそのためです。
FreeTは、実際には別のモナドを「包む」ラッパーにすぎません。内包されているモナドを取り出すにはrunFreeTという関数を使います。F#でのケース名をRunとしたのは、これが理由です。
このモナドのスタックをファンクターにするのは簡単です。
// ('a -> 'b) -> CommandLineProgram<ReservationsApiProgram<'a>>
// -> CommandLineProgram<ReservationsApiProgram<'b>>
let private mapStack f x = commandLine {
let! x' = x
return ReservationsApi.map f x' }
mapStack関数は、commandLineコンピュテーション式を使って、CommandLineProgramの中に含まれるReservationsApiProgramにアクセスします。let!束縛によって、x'はReservationsApiProgram<'a>型の値になります。これに対してReservationsApi.mapを使い、x'を関数fでマップできます。
これで、CommandLineReservationsApiT<'a>をファンクターにするのも簡単です。
// ('a -> 'b) -> CommandLineReservationsApiT<'a>
// -> CommandLineReservationsApiT<'b>
let private mapT f (Run p) = mapStack f p |> Run
mapT関数は、単純にRunケースからモナドスタックを取り出してパターンマッチし、mapStackを呼び出して、その結果を再びRunケースに入れて返します。
ここまで来れば、以前と同じレシピに従っていることは明らかでしょう。ファンクターがあるので、それを使ってモナドを作ります。まず、モナドのための型を定義しましょう。
type CommandLineReservationsApiProgram<'a> =
| Free of CommandLineReservationsApiT<CommandLineReservationsApiProgram<'a>>
| Pure of 'a
次に、bind関数を追加します。
// ('a -> CommandLineReservationsApiProgram<'b>)
// -> CommandLineReservationsApiProgram<'a>
// -> CommandLineReservationsApiProgram<'b>
let rec bind f = function
| Free instruction -> instruction |> mapT (bind f) |> Free
| Pure x -> f x
これは、先ほどのReservationsApi用のbind関数とほとんど同じコードです。唯一の違いは、内部で使われるマップ関数がmapIではなくmapTである点です。ただし、扱っている型はもちろん異なります。
map関数も追加できます。
// ('a -> 'b) -> (CommandLineReservationsApiProgram<'a>
// -> CommandLineReservationsApiProgram<'b>)
let map f = bind (f >> Pure)
これもまたコピー&ペーストです。とってもくり返しだわんわん。
モナドスタックを作る場合、構成要素となる各モナドの値を、結合されたモナドの値へと「持ち上げる(lift)」方法が必要になります。Haskellではlift関数とliftF関数がこれを行いますが、F#では自分でそうした関数を明示的に追加する必要があります。
// CommandLineProgram<ReservationsApiProgram<'a>>
// -> CommandLineReservationsApiProgram<'a>
let private wrap x = x |> Run |> mapT Pure |> Free
// CommandLineProgram<'a> -> CommandLineReservationsApiProgram<'a>
let liftCL x = wrap <| CommandLine.map ReservationsApiProgram.Pure x
// ReservationsApiProgram<'a> -> CommandLineReservationsApiProgram<'a>
let liftRA x = wrap <| CommandLineProgram.Pure x
プライベート関数wrapは、内部の「裸の」モナドスタック(CommandLineProgram<ReservationsApiProgram<'a>>)を受け取り、それをCommandLineReservationsApiProgram<'a>型の値に変換します。まず、xをRunでラップしてCommandLineReservationsApiT<'a>型の値にします。次に、その値をmapT PureにパイプしてCommandLineReservationsApiT<CommandLineReservationsApiProgram<'a>>型の値を得ます。最後にこれをFreeにパイプすることで、目的のCommandLineReservationsApiProgram<'a>型の値が生成されます。ふぅ、やれやれ!
liftCL関数は、CommandLineProgram(CL)をCommandLineReservationsApiProgramへと持ち上げます。まずCommandLine.mapを使ってxをCommandLineProgram<ReservationsApiProgram<'a>>型の値に持ち上げ、その結果をwrap関数にパイプします。
同様に、liftRA関数はReservationsApiProgram(RA)をCommandLineReservationsApiProgramへと持ち上げます。これは単純に、xをCommandLineProgram.Pureを使ってCommandLineProgram型の値に昇格させ、その結果をwrap関数にパイプします。
これらの関数では、少し珍しい後方パイプ演算子<|を使いました。こうすることで、liftCLとliftRAの類似性が際立つからです。型注釈を取り除くと、よりはっきりと分かります。
let liftCL x = wrap <| CommandLine.map ReservationsApiProgram.Pure x
let liftRA x = wrap <| CommandLineProgram.Pure x
これが、私が普段書いているF#コードのスタイルです。型注釈は、読者の皆さんのために特別に追加しています。通常、IDEを使っていれば、組み込みのツールでいつでも型を調べられます。
後方パイプ演算子を使うと、どちらの関数もwrap関数に依存していることが一目瞭然になります。これを通常の前方パイプ演算子で書くと、次のようになり、構造が少し分かりにくくなります。
let liftCL x = CommandLine.map ReservationsApiProgram.Pure x |> wrap
let liftRA x = CommandLineProgram.Pure x |> wrap
動作は同じですが、wrapの位置が揃わないため、2つの関数の関連性を見つけるのが少し難しくなります。私が後方パイプ演算子を使うのは、読みやすさを考慮してのことです。
フリーモナドのレシピに従って、コンピュテーション式ビルダーを作りましょう。
type CommandLineReservationsApiBuilder () =
member this.Bind (x, f) = CommandLineReservationsApi.bind f x
member this.Return x = Pure x
member this.ReturnFrom x = x
member this.Zero () = Pure ()
最後に、このクラスのインスタンスを作成します。
let commandLineReservationsApi = CommandLineReservationsApiBuilder ()
commandLineReservationsApiの値をモジュールに入れておけば、そのモジュールを開くたびにこのコンピュテーション式が使えるようになります。私は通常、これを[<AutoOpen>]属性付きのモジュールに入れるようにしています。そうすれば、そのモジュールを含む名前空間を開くだけで、自動的に利用可能になります。
簡略化
F#コードにおいて、単一ケースの判別共用体を導入することに正当な理由がある場合もありますが、それらは内部の型と「同型(isomorphic)」です(つまり、共用体型と内部の型との間で、情報を失うことなく双方向に変換可能であるということです)。フリーモナドのレシピに従って、CommandLineReservationsApiTを判別共用体として導入しましたが、これは単一ケースの共用体なので、内部の型にリファクタリングすることが可能です。
CommandLineReservationsApiT型を削除する場合、まずプログラム型の定義を次のように変更する必要があります。
type CommandLineReservationsApiProgram<'a> =
| Free of CommandLineProgram<ReservationsApiProgram<CommandLineReservationsApiProgram<'a>>>
| Pure of 'a
ここでは単純に、CommandLineReservationsApiT<_>をCommandLineProgram<ReservationsApiProgram<_>>に置き換えています。これにより、事実上、Runケースが保持していた型をFreeケースのコンテナ型へと「昇格」させています。
CommandLineReservationsApiTを削除したら、mapT関数も削除し、bind関数を次のように修正する必要があります。
// ('a -> CommandLineReservationsApiProgram<'b>)
// -> CommandLineReservationsApiProgram<'a>
// -> CommandLineReservationsApiProgram<'b>
let rec bind f = function
| Free instruction -> instruction |> mapStack (bind f) |> Free
| Pure x -> f x
同様に、wrap関数も調整が必要です。
let private wrap x = x |> mapStack Pure |> Free
これ以外のコードは、そのままで問題ありません。
ウィザード
HaskellではFreeT型のおかげでモナドの組み合わせが簡単に手に入りますが、F#ではそのために一手間かける必要があります。しかし、一度モナド形式で組み合わせを手に入れれば、その組み合わせを使ってプログラムを書くことができます。以下に示すのは、ユーザーからデータを集め、ユーザーに代わってレストランの予約を試みるウィザードです。
// CommandLineReservationsApiProgram<unit>
let tryReserve = commandLineReservationsApi {
let! count = liftCL readQuantity
let! date = liftCL readDate
let! availableSeats =
ReservationsApi.getSlots date
|> ReservationsApi.map (List.sumBy (fun slot -> slot.SeatsLeft))
|> liftRA
if availableSeats < count
then do!
sprintf "Only %i remaining seats." availableSeats
|> CommandLine.writeLine
|> liftCL
else
let! name = liftCL readName
let! email = liftCL readEmail
do! { Date = date; Name = name; Email = email; Quantity = count }
|> ReservationsApi.postReservation
|> liftRA
}
tryReserveが関数ではなく値である点に注意してください。これは純粋な値であり、実行したい副作用のあるインタラクションを記述したAST(一種の小さなプログラム)を内包しています。そして、これは完全にcommandLineReservationsApiコンピュテーション式の中で定義されています。
まず、前回のF#の記事で見たreadQuantityとreadDateというプログラム値を使います。これらはどちらもCommandLineProgram型の値なので、liftCLを使ってCommandLineReservationsApiProgram型の値に持ち上げる必要があります。そうして初めて、let!で結果をそれぞれint型とDateTimeOffset型に束縛できます。これは、前回の記事で見たHaskellの例におけるliftの使い方と似ています。
プログラムがユーザーから希望のdateを受け取ったら、ReservationsApi.getSlotsを呼び出し、返されたすべてのSeatsLeft(空席数)の合計を計算します。ReservationsApi.getSlots関数はReservationsApiProgram<Slot list>を返します。これをReservationsApi.mapでReservationsApiProgram<int>型の値に変換し、さらにliftRAで持ち上げて、let!でint型の値に束縛できるようにします。繰り返しになりますが、ここまでの処理でプログラムは実際には何も実行していません。これらの操作を行うための命令を持つASTを構築しているだけです。
もし空席数が足りなければ、プログラムはその旨をコマンドラインに表示して終了します。十分な空席があれば、ユーザーの名前とメールアドレスの入力を促します。これでReservationレコードを作成し、ReservationsApi.postReservationに渡すために必要なデータがすべて揃いました。
インタープリター
tryReserveウィザードは純粋な値です。これは、副作用を伴う操作を実行するように解釈(interpret)できるASTを含んでいます。CommandLineProgramのインタープリターについては前の記事で既に説明したので、ここでは繰り返しません。ただし、結合後のインタープリターにinterpretという名前を使いたかったので、元のインタープリター名をinterpretCommandLineに変更した点だけ補足しておきます。
ReservationsApiProgramの値を解釈するインタープリターは、CommandLineProgramのインタープリターと似たような作りになります。
// ReservationsApiProgram<'a> -> 'a
let rec interpretReservationsApi = function
| ReservationsApiProgram.Pure x -> x
| ReservationsApiProgram.Free (GetSlots (d, next)) ->
ReservationHttpClient.getSlots d
|> Async.RunSynchronously
|> next
|> interpretReservationsApi
| ReservationsApiProgram.Free (PostReservation (r, next)) ->
ReservationHttpClient.postReservation r |> Async.RunSynchronously
next |> interpretReservationsApi
interpretReservationsApi関数は、(暗黙的に渡される)ReservationsApiProgram引数をパターンマッチし、それぞれの命令に応じた適切なアクションを実行します。すべてのFreeケースでは、ReservationHttpClientモジュールで定義された実装に処理を委譲します。このモジュールのコードはここには示していませんが、この記事に付属するGitHubリポジトリで確認できます。
これら2つの「末端(leaf)」のインタープリターを組み合わせて、CommandLineReservationsApiProgramの値を解釈するインタープリターを作ることができます。
// CommandLineReservationsApiProgram<'a> -> 'a
let rec interpret = function
| CommandLineReservationsApiProgram.Pure x -> x
| CommandLineReservationsApiProgram.Free p ->
p |> interpretCommandLine |> interpretReservationsApi |> interpret
通常通り、Pureケースでは、中に含まれる値をそのまま返します。Freeケースでは、pはCommandLineProgram<ReservationsApiProgram<CommandLineReservationsApiProgram<'a>>>型の値です。これはCommandLineProgramの値なので、interpretCommandLineで解釈できます。その結果はReservationsApiProgram<CommandLineReservationsApiProgram<'a>>型になります。これはReservationsApiProgramの値なので、今度はinterpretReservationsApiに渡して解釈できます。その結果はCommandLineReservationsApiProgram<'a>型になります。この型のためのインタープリターも存在します。それがinterpret関数自身です。したがって、ここで再帰的にinterpretを呼び出します。言い換えると、interpretはPureケースに到達するまで再帰を繰り返します。
実行
これで、プログラムを実行するためのすべての準備が整いました。以下がプログラムのエントリーポイントです。
[<EntryPoint>]
let main _ =
interpret Wizard.tryReserve
0 // return an integer exit code
実行すると、次のようなインタラクションが可能になります。
Please enter number of diners:
4
Please enter your desired date:
2017-11-25
Please enter your name:
Mark Seemann
Please enter your email address:
mark@example.net
OK
このコードサンプルを自分で試してみたい場合は、インタラクション可能な適切なHTTP APIを用意する必要があります。私はローカルマシン上でAPIをホストし、実行後、実際に予約レコードがデータベースに書き込まれたことを確認しました。
まとめ
予想通り、F#でもフリーモナドを組み合わせることは可能です。ただし、Haskellと比べると、より多くの定型コード(ボイラープレート)が必要になります。
次回: F#フリーモナドのレシピ