Build. Translate. Understand.

F# のフリーモナドレシピ

2025-04-11 14:00:25

原文: F# free monad recipe by Mark Seemann

F#でフリーモナドを作る方法

これはデザインパターンではありませんが、関連するものです。レシピと呼ぶことにしましょう。私の考えでは、デザインパターンは基本的に言語に依存しないものであるべきです(ただし万能というわけではありません)。一方、この記事ではF#における特定の問題に焦点を当てています:

F#でフリーモナドをどう作るか?

このレシピに従えばいいのです。

ここで紹介するレシピはステップバイステップのプロセスですが、まず動機と使用時期のセクションをお読みください。フリーモナドはそれ自体が目的ではありません。

この記事はフリーモナドの詳細を説明するものではなく、参考資料として役立てることを目的としています。フリーモナドの入門としては、私の記事Pure timesが良いスタート地点です。また、下記の動機となる例セクションもご覧ください。

動機

F#についてよく聞かれる質問の一つが:F#においてインターフェースに相当するものは何か?です。この質問に対する単一の回答はありません。いつものように、それは状況次第™です。そもそもなぜインターフェースが必要なのか?どのような用途を意図しているのか?

オブジェクト指向プログラミングでは、インターフェースがStrategyパターンとして使われることがあります。これにより、実行時に異なる(サブ)アルゴリズム間で動的に切り替えや選択が可能になります。アルゴリズムが純粋な場合、F#らしい対応物は関数ということになります。

しかし、質問者がDI(依存関係の注入)を念頭に置いている場合もあります。オブジェクト指向では、依存関係は複数のメンバーを持つインターフェースとしてモデル化されることが多いです。このような依存関係は本質的に不純であり、関数型設計の一部ではありません。可能であれば、インタラクションよりも不純/純粋/不純のサンドイッチ構造を優先すべきです。しかし時には、インターフェースや抽象クラスのように機能するものが必要になることもあります。フリーモナドはこのような状況に対応できます。

一般的に、フリーモナドは任意のファンクターからモナドを構築することを可能にしますが、なぜそうしたいのでしょうか?私が最も多く見かける理由は、不純なインタラクションを純粋な方法でモデル化するため、つまりDIの代替としてです。

インターフェースをファンクターにリファクタリングする

このレシピは3つの部分から成ります:

  1. インターフェースをファンクターにリファクタリングするためのレシピ
  2. 任意のファンクターからモナドを作成するための核心的なレシピ
  3. インタープリターを追加するためのレシピ

任意のファンクターからモナドを作成するための普遍的なレシピは後のセクションで説明します。このセクションでは、インターフェースをファンクターにリファクタリングする方法を見ていきます。

リファクタリングしたいインターフェースがあるとします。C#ではこのように見えるでしょう:

public interface IFace
{
    Out1 Member1(In1 input);
    Out2 Member2(In2 input);
}

F#では、このようになります:

type IFace =
    abstract member Member1 : input:In1 -> Out1
    abstract member Member2 : input:In2 -> Out2

特定の例ではなくレシピ自体を示すため、意図的にインターフェースを曖昧で抽象的に保っています。現実的な例については、下のセクションを参照してください。

このようなインターフェースをファンクターにリファクタリングするには、次の手順に従います:

  1. 判別共用体を作成します。インターフェース名にinstructionという接尾辞を付けて名付けます。
  2. 共用体の型をジェネリックにします。
  3. インターフェースの各メンバーに対して、ケースを追加します。
    1. メンバーの名前に基づいてケースに名前を付けます。
    2. ケースに含まれるデータの型をペア(2要素のタプル)として宣言します。
    3. そのタプルの最初の要素の型を、インターフェースメンバーへの入力引数の型として宣言します。メンバーに複数の入力引数がある場合は、(入れ子の)タプルとして宣言します。
    4. タプルの2番目の要素を関数として宣言します。その関数の入力型は元のインターフェースメンバーの出力型であるべきで、関数の出力型は共用体型のジェネリック型引数であるべきです。
  4. 共用体の型にmap関数を追加します。名前の競合を避けるため、この関数をプライベートにし、mapという名前を使わないことをお勧めします。私は通常この関数をmapIと名付けます(Iinstructionの略です)。
  5. このmap関数は、最初の(カリー化された)引数として'a -> 'b型の関数を取り、2番目の引数として共用体の型の値を取る必要があります。共用体の型の値を返しますが、ジェネリック型引数が'aから'bに変更されています。
  6. 共用体の型の各ケースについて、同じケースの値にマップします。ペアの(非ジェネリックな)最初の要素はそのままコピーし、2番目の要素の関数をmap関数への入力関数と合成します。

このレシピに従うと、上記のインターフェースは次のような共用体の型になります:

type FaceInstruction<'a> =
| Member1 of (In1 * (Out1 -> 'a))
| Member2 of (In2 * (Out2 -> 'a))

map関数はこうなります:

// ('a -> 'b) -> FaceInstruction<'a> -> FaceInstruction<'b>
let private mapI f = function
    | Member1 (x, next) -> Member1 (x, next >> f)
    | Member2 (x, next) -> Member2 (x, next >> f)

このような共用体の型とmap関数の組み合わせはファンクターの法則を満たします。これで、インターフェースをファンクターにリファクタリングできました。

フリーモナドレシピ

任意のファンクターからモナドを作ることができます。作られるモナドはファンクターを含む新しい型になります。ファンクター自体をモナドに変えるわけではありません(一部のファンクターは自身をモナドに変換できますが、その場合はフリーモナドを作る必要はありません)。

任意のファンクターをモナドに変えるレシピは次のとおりです:

  1. ジェネリックな判別共用体を作成します。基礎となるファンクターの名前にProgramのような接尾辞を付けることができます。以下では、これを「プログラム」共用体型と呼びます。
  2. 共用体にFreePureの2つのケースを追加します。
  3. Freeケースには、含まれるファンクターの単一の値を含めます。これは「プログラム」共用体型自体にジェネリックに型付けされています(再帰的な型定義です)。
  4. Pureケースには、共用体のジェネリック型の単一の値を含めます。
  5. 新しい共用体型にbind関数を追加します。この関数は2つの引数を取ります:
  6. bind関数の最初の引数は、ジェネリック型引数を入力として取り、出力として「プログラム」共用体型の値を返す関数です。このレシピの残りの部分では、この関数をfと呼びます。
  7. bind関数の2番目の引数は、「プログラム」共用体型の値です。
  8. bind関数の戻り型は、最初の引数(f)の戻り型と同じジェネリック型を持つ「プログラム」共用体型の値です。
  9. recキーワードを追加して、bind関数を再帰的に宣言します。
  10. FreePureのケースでパターンマッチングを行ってbind関数を実装します:
  11. Freeケースでは、含まれるファンクター値をファンクターのmap関数にパイプし、マッパー関数としてbind fを使います。その結果をFreeにパイプします。
  12. Pureケースでは、Pureケースに含まれる値xに対してf xを返します。
  13. Bindbindを使い、ReturnPureを使うコンピュテーション式ビルダーを追加します。

上記の例を続けると、「プログラム」共用体型は次のようになります:

type FaceProgram<'a> =
| Free of FaceInstruction<FaceProgram<'a>>
| Pure of 'a

注目すべきは、Pureケースは常にこの形になるということです。書くのにそれほど手間はかかりませんが、他のフリーモナドからコピーアンドペーストしても、変更は必要ありません。

レシピによれば、bind関数は次のように実装します:

// ('a -> FaceProgram<'b>) -> FaceProgram<'a> -> FaceProgram<'b>
let rec bind f = function
| Free x -> x |> mapI (bind f) |> Free
| Pure x -> f x

細かい点を除けば、bind関数は常にこのような形になるので、ここからコピーアンドペーストしてコードで使うことができます。唯一の違いは、基礎となるファンクターのmap関数がmapIと呼ばれるとは限らないことですが、もしそう呼ばれていれば、上記のbind関数をそのまま使えます。修正は必要ありません。

F#ではモナドそれ自体が目的になることは稀ですが、モナドを手に入れたらコンピュテーション式ビルダーを追加できます:

type FaceBuilder () =
    member this.Bind (x, f) = bind f x
    member this.Return x = Pure x
    member this.ReturnFrom x = x
    member this.Zero () = Pure ()

他のメンバー(CombineForTryFinallyなど)も追加できますが、通常はこの4つのメソッドで十分だと思います。

ビルダーオブジェクトのインスタンスを作成すれば、コンピュテーション式を書き始めることができます:

let face = FaceBuilder ()

最後に、オプションのステップとして、インターフェースを命令セットにリファクタリングした場合、各命令ケースをフリーモナド型に持ち上げる便利な関数を追加できます:

  1. 各ケースに対して、同じ名前の関数を追加しますが、PascalCaseではなくcamelCaseを使います。
  2. 各関数は、ケースに含まれるタプルの最初の要素(元のインターフェースの入力引数)に対応する入力引数を持つべきです。私は通常、カリー化された形式の引数を好みますが、必須ではありません。
  3. 各関数は、対応する命令共用体ケースをFreeケース内に返すべきです。ケースコンストラクタは、必要なデータのペアで呼び出される必要があります。最初の要素には便利関数への入力引数から値を入れます。2番目の要素は、関数として渡されるPureケースコンストラクタです。

現在の例では、FaceInstruction<'a>の各ケースに対応する2つの関数を作ることになります:

// In1 -> FaceProgram<Out1>
let member1 in1 = Free (Member1 (in1, Pure))
 
// In2 -> FaceProgram<Out2>
let member2 in2 = Free (Member2 (in2, Pure))

このような関数は、基礎となるファンクターが表現するものをフリーモナドのコンテキストで表現しやすくする便利なものです。

インタープリター

フリーモナドは再帰的な型であり、値は木構造となります。葉はPure値です。多くの場合(常にとは言いませんが)、フリーモナドの目的はツリーを評価して葉の値を取り出すことです。そのためには、インタープリターを追加する必要があります。これは、Pureケースに遭遇するまでフリーモナド値を再帰的にパターンマッチする関数です。

少なくともインターフェースをファンクターにリファクタリングした場合、インタープリターを書くこともレシピに含まれます。これはインターフェースを実装する具象クラスを書くことに相当します。

  1. 命令セットファンクターの各ケースに対して、ケースの「入力」タプル要素型を入力として取り、ケースの2番目のタプル要素で使用される型の値を返す実装関数を書きます。2番目の要素はペアの中の関数であることを思い出してください。実装関数の出力型は、その関数の入力型であるべきです。
  2. インタープリターを実装する関数を追加します。私はよくこれをinterpretと呼びます。recキーワードを追加して、再帰的にします。
  3. PureFreeに含まれる各ケースでパターンマッチングを行います。
  4. Pureケースでは、ケースに含まれる値をそのまま返します。
  5. Freeケースでは、命令セットファンクターの各ケースの基礎となるペアをパターンマッチします。そのタプルの最初の要素は「入力値」です。その値を対応する実装関数にパイプし、その戻り値をタプルの2番目の要素に含まれる関数にパイプし、その結果を再帰的にインタープリター関数にパイプします。

2つの実装関数imp1imp2が存在すると仮定します。レシピによれば、imp1In1 -> Out1の型を持ち、imp2In2 -> Out2の型を持ちます。これらの関数を使うと、実行例は次のようになります:

// FaceProgram<'a> -> 'a
let rec interpret = function
    | Pure x -> x
    | Free (Member1 (x, next)) -> x |> imp1 |> next |> interpret
    | Free (Member2 (x, next)) -> x |> imp2 |> next |> interpret

Pureケースは常にこの形です。各Freeケースは異なる実装関数を使いますが、それ以外はほぼ同じ形をしています。

このようなインタープリターは、実装関数が不純であるため、多くの場合不純です。純粋なインタープリターを定義することも可能ですが、通常は用途が限られます。ただし、ユニットテストでは有用な場合があります。

// Out1 -> Out2 -> FaceProgram<'a> -> 'a
let rec interpretStub out1 out2 = function
    | Pure x -> x
    | Free (Member1 (_, next)) -> out1 |> next |> interpretStub out1 out2
    | Free (Member2 (_, next)) -> out2 |> next |> interpretStub out1 out2

このインタープリターは、各Freeケース内の入力値を効果的に無視し、代わりに純粋な値out1out2を使用します。これは本質的にスタブ—常に事前定義された値を返す「実装」です。

重要なのは、インターフェースの実装が複数あり得るのと同様に、純粋または不純な複数のインタープリターを持つことができるということです。

いつ使うべきか

フリーモナドはDIの代わりによく使われます。ただし、フリーモナド値自体は純粋ですが、不純な振る舞いを示唆することに注意してください。私の考えでは、純粋なコードの主な利点は、コードを読む人やメンテナンスする人として、コードが純粋であると知っていれば副作用を心配する必要がないことです。フリーモナドでは、ASTは純粋ですが、不純なインタープリターが副作用を引き起こすため、副作用について心配する必要があります。しかし少なくとも、その副作用は既知であり、操作の小さなサブセットに限定されています。Haskellはこの区別を強制しますが、F#はそうではありません。そこで問題となるのは、あなたがこの種の設計をどれだけ価値あるものと考えるかです。

私は、フリーモナドが不純なことを行う意図を明示的に伝えるため、ある程度の価値はあると思います。この意図はコードベースの型に埋め込まれ、誰もが確認できます。関数が値を生成できない可能性がある場合に'a option値を返すことを好むのと同様に、関数の戻り型から限定された不純な操作のセットが発生する可能性があることがわかるのが好ましいです。

明らかに、F#でフリーモナドを作成するにはボイラープレートコードが必要です。この記事では、そのようなボイラープレートコードを書くことは難しくないことを示したと思います—レシピに従うだけです。ほとんど考える必要もありません。モナドは普遍的な抽象化なので、一度コードを書けば、将来的にそれを頻繁に扱う必要はないでしょう。結局のところ、数学的抽象化は変わりません。

おそらくより重要な懸念は、特定のコードベースの開発者がフリーモナドにどの程度精通しているかです。立場によっては、フリーモナドは認知的負荷が高いと主張することも、逆に認知的負荷を軽減すると主張することもできます。

洞察は、それを理解するまでは不明瞭ですが、理解した後は明確になります。

これはフリーモナドにも当てはまります。理解するには努力が必要ですが、一度理解すれば、それらが単なるパターン以上のものであることに気づきます。それらは法則に従う普遍的な抽象化です。フリーモナドを完全に理解すれば、その認知的負荷は減少します。

フリーモナドと関わることになる開発者を考慮しましょう。もし彼らがすでにフリーモナドを知っているか、モナドについて十分理解していてこれが次のステップになりそうなら、フリーモナドの使用は有益かもしれません。一方、ほとんどの開発者がF#や関数型プログラミングに慣れていない場合、当面はフリーモナドを避けた方が良いでしょう。

このフローチャートは上記の考察をまとめたものです:

フリーモナドを設計原則として選択するかどうかの判断フローチャート。

最初に考慮すべきは、あなたのコンテキストが不純/純粋/不純のサンドイッチを可能にするかどうかです。もしそうなら、必要以上に物事を複雑にする理由はありません。フレッド・ブルックスの用語を使えば、これは偶発的な複雑さを避ける大きな助けになるはずです。

長時間実行される不純なインタラクションを避けられない場合は、純粋性や厳密に関数型の設計があなたにとって重要かどうかを考えてみてください。F#はマルチパラダイム言語であり、不純だが構造の整ったコードを書くことは十分可能です。部分適用をDIの慣用的な代替として使用することもできます。

コードを関数型かつ明示的に保ちたい場合は、フリーモナドの使用を検討してもよいでしょう。この場合でも、問題のコードベースのメンテナを考慮すべきです。関係者全員がフリーモナドに精通しているか、学ぶ意欲がある場合、それは実行可能な選択肢だと思います。そうでなければ、DIがすべてを不純にするとしても、部分適用に戻ることをお勧めします。

動機となる例

コードベースにフリーモナドを導入する最も強力な動機は、長時間実行される不純なインタラクションを関数型スタイルでモデル化することだと考えています。

他のほとんどのソフトウェア設計の考慮事項と同様に、アプリケーションアーキテクチャの全体的な目的は(本質的な)複雑さに対処することです。したがって、例はそのデザインを正当化するのに十分な複雑さを持っている必要があります。C#でのDIのhello world例にはほとんど意味がありません。同様に、フリーモナドを使ったhello world例も正当化されるとは思えません。そのため、例は別の記事で提供されています。

Pure timesの連載は小さいので、出発点として良いと思います。これらの記事は、厳密に関数型プログラミングを使用して特定の本物の問題に対処する方法を示しています。これらの記事は問題解決に焦点を当てているため、物語を進めるために詳細な説明を省略することもあります。

F#でのフリーモナドのすべての要素について詳細な説明が必要な場合は、この連載がまさにそれを提供しており、特に純粋なコマンドラインインタラクション入門の記事が参考になります。

バリエーション

上記のレシピは標準的なシナリオを説明しています。もちろんバリエーションも可能です。異なる命名戦略なども選べますが、これについては詳しく説明しません。

しかし、いくつか言及に値する特殊なケースがあります。インタラクションがデータを返さない、または入力を取らない場合があります。F#では、データの欠如を常にunit())としてモデル化できるので、Foo of (unit * Out1 -> 'a)Bar of (In2 * unit -> 'a)のような命令ケースを定義することは確かに可能です。しかし、unitはデータを含まないため、抽象化を変更せずにそれを削除できます。

純粋なコマンドラインインタラクション入門記事には、両方の特殊なケースを例示する単一の型が含まれています。それはこの命令セットを定義しています:

type CommandLineInstruction<'a> =
| ReadLine of (string -> 'a)
| WriteLine of string * 'a

ReadLineケースは入力を取らないため、入力と継続のペアを含む代わりに、このケースは継続関数のみを含みます。同様に、WriteLineケースも特殊ですが、こちらは出力がありません。このケースは確かにペアを含みますが、2番目の要素は関数ではなく、値です。

これはファンクターとモナド関数の実装にいくつかの表面的な影響を与えます。例えば、mapI関数はこうなります:

// ('a -> 'b) -> CommandLineInstruction<'a> -> CommandLineInstruction<'b>
let private mapI f = function
    | ReadLine next -> ReadLine (next >> f)
    | WriteLine (x, next) -> WriteLine (x, next |> f)

ReadLineケースではパターンマッチングするタプルがないことに注意してください。代わりに、nextに直接アクセスできます。

WriteLineケースでは、戻り値が関数合成(next >> f)から通常の関数呼び出し(next |> f、これはf nextに相当します)に変わります。

lift関数も変わります:

// CommandLineProgram<string>
let readLine = Free (ReadLine Pure)
 
// string -> CommandLineProgram<unit>
let writeLine s = Free (WriteLine (s, Pure ()))

入力がないため、readLineは関数ではなく値に変わります。一方、writeLineは関数のままですが、ペアの2番目の要素として通常の関数(Pure)ではなくPure ())を渡す必要があります。

このような軽微な変更を除けば、入力や出力にunit値を省略することはほとんど影響ありません。

上記のレシピからもう一つのバリエーションは、インタープリターに関するものです。上記のレシピでは、各命令に対して実装関数を作成する方法を説明しました。しかし、その関数が数行のコードだけの場合もあります。そのような場合、私は時々その関数をインタープリター内に直接インラインします。再び、CommandLineProgram APIが例を提供します:

// CommandLineProgram<'a> -> 'a
let rec interpret = function
    | Pure x -> x
    | Free (ReadLine  next) -> Console.ReadLine () |> next |> interpret
    | Free (WriteLine (s, next)) ->
        Console.WriteLine s
        next |> interpret

ここでは、Console.ReadLineConsole.WriteLineがすでに存在し、望む目的を果たしているため、カスタム実装関数は必要ありません。

まとめ

この記事では、インターフェースをフリーモナドにリファクタリングするための反復可能で自動化可能なプロセスを説明しました。私はこれを何度も行ってきたので、このプロセスは常に可能だと信じていますが、形式的な証明はありません。

また、逆のプロセスも可能だと強く推測しています。フリーモナドに昇格された任意の命令セットに対して、オブジェクト指向のインターフェースを定義できるはずです。これが真なら、オブジェクト指向のインターフェースとASTベースのフリーモナドは同型です。


インデックスへ戻る