Build. Translate. Understand.

純粋なコマンドラインインタラクション入門

2025-04-09 18:45:25

原文: Hello, pure command-line interaction by Mark Seemann

純粋なコードで不純なインタラクションを優雅に表現する入門

オブジェクト指向プログラミングにおける依存関係の注入は、広く知られた概念です。しかし、以前に述べたように、すべてを不純にするため、関数型プログラミングとは相容れません。 一般的に、アプリケーションは不純/純粋/不純のサンドイッチという考え方に基づいて設計して、依存関係という概念は排除すべきです。 これは意外と可能であることが多いのですが、それでもこの方法が通用しないケースも少なくありません。 アプリケーションが長時間にわたって不純な世界とやり取りする必要がある場合、そうしたインタラクションを純粋な形で表現する方法が求められます。

この記事では、そのための手法を紹介します。

コマンドラインAPIについて

一連の質問を投げかけ、それに応じた応答を出力するコマンドラインプログラムを作成する必要があるとしましょう。 多くの場合、これはユーザーとプログラム間で、長時間にわたる一連のやり取りとなります。 ただし、この記事では話を簡単にするため、まずは単純な例から見ていくことにします。

Please enter your name.
Mark
Hello, Mark!

このプログラムは、単にあなたの名前の入力を促すだけです。 入力が終わると、挨拶が出力されます。オブジェクト指向プログラミングでは、依存関係の注入を用いてインターフェースを導入するかもしれません。 話を単純にするため、ここではインターフェースを2つのメソッドに限定してみます。

public interface ICommandLine
{
    string ReadLine();
    void WriteLine(string text);
}

これはあくまでもおもちゃのような例であることに注意してください。 後の記事では、この例をどのように発展させて、より複雑なやり取りに対応させるかを見ていきます。より現実的な例については、こちらで既に触れられています。 今回の例は、やり取りが1回しかないという点で、非常に単純です。 この場合でも、不純/純粋/不純のサンドイッチは可能ですが、こうした設計は、より複雑なやり取りには拡張できません。

インターフェースを定義して注入することの問題点は、それが関数型プログラミング的ではないということです。では、関数型プログラミングにおける同等のものは何でしょうか?

命令セットの定義

インターフェースを定義する代わりに、コマンドラインでのやり取りのための、限定された命令セットを記述する判別共用体を定義することができます。

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

これは、上記のC#インターフェースと少し似ているかもしれません。 2つのメソッドを定義する代わりに、2つのケースを定義していますが、名前は似ています。

ReadLineケースは、インタープリターが評価できる「命令」です。 このケースに含まれるデータは、継続関数です。 インタープリターは、命令を評価した後、この関数を文字列とともに呼び出す必要があります。 どの文字列を使用するかはインタープリター次第ですが、たとえば、コマンドラインから入力文字列を読み取るといった方法が考えられます。 継続関数は、記述しているプログラムにおける次のステップとなります。

WriteLineケースは、インタープリターに対する別の命令です。 このケースに含まれるデータは、タプルです。タプルの最初の要素はインタープリターへの入力であり、たとえば、値をコマンドラインに出力したり、無視したりすることができます。 タプルの2番目の要素は、このケースが含まれるプログラムを継続するために使用される値です。

これにより、小さく特化した抽象構文木(AST)を記述できますが、現時点では、そこから戻る方法はありません。 そのための1つの方法は、3番目の「停止」ケースを追加することです。 このオプションに関心がある方は、Scott Wlaschin氏によるAST設計に関する優れた解説で、この方法が1つの選択肢として取り上げられています。

CommandLineInstruction<'a>に3番目の「停止」ケースを追加する代わりに、これをラップする新しい型を追加することもできます。

type CommandLineProgram<'a> =
| Free of CommandLineInstruction<CommandLineProgram<'a>>
| Pure of 'a

Freeケースには、常に新しいCommandLineProgram値に続くCommandLineInstructionが含まれています。 ASTから抜け出すための唯一の方法は、単に「戻り値」を格納するPureケースを経由することです。

抽象構文木(AST)

これらの2つの型を使用すると、インタープリターに対する命令を含む、特殊なプログラムを記述できます。 F#では明確には分かりませんが、これらの型は意図的に純粋になるように設計されていることに注意してください。 ただし、Haskellでは、この点を明確にすることができます。Haskellでは、命令セットは次のようになります。

data CommandLineInstruction next =
    ReadLine (String -> next)
  | WriteLine String next
  deriving (Functor)
 
type CommandLineProgram = Free CommandLineInstruction

これらの型はどちらも純粋です。なぜなら、IOがどこにも見当たらないからです。 Haskellでは、関数はデフォルトで純粋です。これは、ReadLineケースに含まれるString -> next関数にも当てはまります。

話をF#に戻すと、記事の冒頭で紹介したコマンドラインでのやり取りを実装するASTは、次のように記述できます。

// CommandLineProgram<unit>
let program =
    Free (WriteLine (
            "Please enter your name.",
            Free (ReadLine (
                    fun s -> Free (WriteLine (
                                    sprintf "Hello, %s!" s,
                                    Pure ()))))))

このASTは、小さなプログラムを定義します。 最初のステップは、入力値"Please enter your name."を持つWriteLine命令です。 WriteLineケースコンストラクターは、入力引数としてタプルを取ります。 最初のタプル要素はこのプロンプトで、2番目の要素は継続です。これは、新しいCommandLineInstruction<CommandLineProgram<'a>>値である必要があります。

この例では、継続値はReadLineケースであり、継続関数を入力として取ります。 この関数は、WriteLineを返すことによって、新しいプログラム値を返す必要があります。

この2番目のWriteLine値は、外側の値sからstringを作成します。 WriteLineケースの2番目のタプル要素も、やはり新しいプログラム値でなければなりません。しかし、ここでプログラムは完了するので、「停止」値Pure ()を使用できます。

おそらく、あなたは私がどうかしていると思われるでしょう。まともな人間なら、こんなコードは書きたくないはずです。 私もそう思います。幸いなことに、コーディング体験は大幅に改善できます。その方法については、後ほど説明します。

インタープリター

上記のprogram値は、小さなCommandLineProgram<unit>です。これは純粋な値であり、それ自体では何も行いません。

もちろん、私たちはこのプログラムに何かを実行してもらいたいと考えます。そのためには、インタープリターを記述する必要があります。

// 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

このインタープリターは、CommandLineProgram<'a>内のすべてのケースをパターンマッチングする再帰関数です。 Pureケースに遭遇すると、単に格納されている値を返します。

ReadLine値に遭遇すると、Console.ReadLine()を呼び出します。これにより、コマンドラインから読み取られたstring値が返されます。 次に、この入力値をnext継続関数に渡し、新しいCommandLineInstruction<CommandLineProgram<'a>>値を生成します。 最後に、この継続値を再帰的にインタープリター自身に渡します。

同様の処理がWriteLineケースにも適用されます。 Console.WriteLine sは、sをコマンドラインに出力し、その後、nextが再帰的にinterpretに渡されます。

interpret programを実行すると、次のようなやり取りが得られます。

Please enter your name.
ploeh
Hello, ploeh!

programは純粋ですが、interpret関数は不純です。

糖衣構文の導入

言うまでもなく、上記のようなASTでプログラムを記述したいと思う人はいないでしょう。幸いなことに、そうする必要はありません。 コンピュテーション式の形で、糖衣構文を追加することができます。 そのためには、AST型をモナドに変換する必要があります。 Haskellでは、Freeはモナドであるため、この作業は既に完了しています。F#では、いくつかのコードを記述する必要があります。

ソースファンクター

最初のステップは、基になる命令セット共用体型のマップ関数を定義することです。 概念的には、型に対してマップ関数を定義できる場合、(ファンクターの法則に従うのであれば)ファンクターを作成したことになります。 ファンクターは一般的な概念であるため、意識しておくと役立つことがよくあります。

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

mapI関数は、CommandLineInstruction<'a>値を受け取り、「基になる値」をマッピングすることによって、新しい値にマッピングします。 後でCommandLineProgram<'a>のマップ関数も定義する予定なので、この関数をprivateにしました。APIのユーザーを2つの異なるマップ関数で混乱させたくないからです。 これが、関数名が単にmapではなく、Iinstructionを表すmapIとなっている理由でもあります。

mapIは、(暗黙の)入力引数に対してパターンマッチングを行います。ReadLineケースの場合、新しいReadLine値を返しますが、マッパー関数fを使用してnext関数を変換します。 nextstring -> 'a型の関数であることを思い出してください。 これをf'a -> 'b型の関数)と合成すると、(string -> 'a) >> ('a -> 'b)、つまりstring -> 'bが得られます。 これで、ReadLineケースの'a'bに変換されました。 WriteLineケースに対しても同様の処理を行うことができれば、ファンクターが得られます。

幸いなことに、WriteLineケースも似ていますが、少し調整が必要です。このケースには、データのタプルが含まれています。最初の要素(x)はジェネリック型ではないため(string型です)、マッピングする要素はありません。 そのため、これはそのまま、返される新しいWriteLine値で使用できます。 WriteLineケースは、nextが関数ではなく値であるという点で、単純です。 next'a型であり、f'a -> 'b型の関数であるため、nextfに渡すと'bが返されます。

これで完了です。これでファンクターが完成しました。

(圏論の専門家の方々のために補足しておくと、ここで説明したファンクターは、実際にはエンドファンクターと呼ばれるファンクターのサブタイプです。 さらに、ファンクターとして認められるためには、ファンクターはいくつかの単純で直感的な法則に従う必要がありますが、ここではこれ以上深くは立ち入りません。)

フリーモナドについて

私がファンクターについて多くの時間を割いて説明しているのには理由があります。 その目的は、糖衣構文を導入することです。 これは、コンピュテーション式を使用することで実現できます。 コンピュテーション式ビルダーを作成するには、モナドが必要です。

モナドを作成するためのレシピが必要です。 幸いなことに、フリーモナドと呼ばれるモナドの一種があります。 フリーモナドには、任意のファンクターからモナドを作成できるという利点があります。

これはまさに必要なものです。

Haskellでは、type CommandLineProgram = Free CommandLineInstructionと宣言すると、この処理は自動的に行われます。 Haskellの型システムのおかげで、基になる型がFunctorの場合、Freeは自動的にMonadになります。 F#でモナドを使用するには、いくつかの作業が必要になりますが、Haskellがこの処理を自動化できるということは、従うことができるレシピがあるということを意味します。

この記事の前半で、命令セットに「停止」ケースを定義する代替方法があることについて簡単に触れました。 APIを2つの型(「命令セット」と「プログラム」)に分けたのは、命令セットが基になるファンクターであるためです。 「プログラム」は、フリーモナドの一部です。 もう1つの部分は、(モナドの法則に従う)bind関数です。

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

この再帰関数は、(暗黙の)CommandLineProgram<'a>引数に対してパターンマッチングを行います。 Pureケースでは、「戻り値」x'a型であり、これはf関数への入力として適切です。 結果は、CommandLineProgram<'b>型の値になります。

Freeケースでは、instructionはマップ関数mapIを持つファンクターです。 mapI関数への最初の引数は、'a -> 'b型の関数である必要があります。 このような関数はどのように合成すればよいでしょうか。

再帰的なbind関数をfで部分的に適用すると(つまり、bind f)、CommandLineProgram<'a> -> CommandLineProgram<'b>型の関数が得られます。 instructionCommandLineInstruction<CommandLineProgram<'a>>型であるため(必要な場合は、Freeケースの定義を参照してください)、これはmapIに適合します。 instructionmapIを呼び出した結果は、CommandLineInstruction<CommandLineProgram<'b>>値になります。 これをCommandLineProgram<'b>値に変換するには、新しいFreeケースでラップします。

これには少し説明が必要でしたが、フリーモナドのbind関数を定義するプロセスは繰り返し行うことができます。 結局のところ、Haskellではこの処理は自動化されています。 F#では、コードを明示的に記述する必要がありますが、手順は決まっています。 いったん理解してしまえば、それほど難しいことではありません。

ファンクターについて

bind関数は、明示的に使用する必要がある場合もありますが、多くの場合、コンピュテーション式に「消え」ます。 ただし、APIの構成要素は、bind関数だけではありません。 たとえば、map関数が必要になる場合があります。

// ('a -> 'b) -> CommandLineProgram<'a> -> CommandLineProgram<'b>
let map f = bind (f >> Pure)

これにより、CommandLineProgram<'a>もファンクターになります。 これが、mapIをprivateにした理由です。 mapIは命令セットをファンクターにしますが、APIはASTプログラムの用語で表現されており、一貫性を持たせる必要があるためです。 同じモジュール内では、mapbindと同じデータ型で動作する必要があります。

mapは、bindPureの組み合わせとして定義できることに注意してください。 これは、レシピの一部です。 フリーモナドの場合、map関数は常に次のようになります。 f関数は'a -> 'b型の関数であり、Pure'b -> CommandLineProgram<'b>型のケースコンストラクターです。 通常の'aの代わりに、ジェネリック型引数として'bを使用したことに注意してください。 うまくいけば、これにより、これらの2つの関数(f >> Pure)を合成すると、('a -> 'b) >> ('b -> CommandLineProgram<'b>)、つまり'a -> CommandLineProgram<'b>型の関数が得られることが明確になります。 これは、bind関数に必要な関数の型であるため、全体の合成は型チェックを通過し、意図したとおりに機能します。

APIについて

APIを操作するには、APIの型の値を作成する機能が必要です。 この場合、CommandLineProgram<'a>値を作成できる必要があります。 ReadLineWriteLineFree、およびPureケースコンストラクターを使用して明示的に作成することもできますが、事前定義された関数と値を用意しておくとより便利です。

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

ReadLineケースには、命令への入力がないため、readLineを事前定義されたCommandLineProgram<string>値として定義できます。

一方、WriteLineケースは、書き込む文字列を入力引数として取るため、writeLineCommandLineProgram<unit>値を返す関数として定義できます。

コンピュテーション式について

正直なところ、mapとサポートAPIの追加は、少し寄り道です。 これらの関数は後で使用しますが、コンピュテーション式ビルダーを作成するために必須ではありません。 必要なのは、bind関数と、生の値をモナドにリフトする方法だけです。 これらのすべてが揃っているので、ビルダーは委任の問題になります。

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

これは非常にシンプルなビルダーですが、私の経験では、ほとんどの場合、これで十分です。

CommandLineBuilderクラスのインスタンスを作成すると、コンピュテーション式を記述できます。

let commandLine = CommandLineBuilder ()

通常、このようなオブジェクトは[<AutoOpen>]属性を持つモジュールに配置するため、グローバルオブジェクトとして使用できます。

読みやすいコードでASTを生成する

commandLineコンピュテーション式を使用することは、組み込みのasyncまたはseq式を使用することに似ています。 これを使用すると、上記のASTを読みやすいコードとして書き換えることができます。

// CommandLineProgram<unit>
let program =
    commandLine {
        do!  CommandLine.writeLine "Please enter your name."
        let! name = CommandLine.readLine
        do!  sprintf "Hello, %s!" name |> CommandLine.writeLine }

これにより、前と同じASTが生成されますが、構文がはるかに読みやすくなります。 ASTは同じであり、上記のinterpret関数を使用して実行できます。 インタラクションは以前と同じです。

Please enter your name.
Free
Hello, Free!

これは明らかに単純な例ですが、今後の記事では、コードを徐々に拡張して、より複雑なインタラクションを実行する方法について説明します。

まとめ

関数型プログラミングは、純粋関数と、純粋コードと不純コードの分離を重視します。 このような分離を実現する最も簡単な方法は、コードを不純/純粋/不純のサンドイッチとして設計することですが、これが不可能な場合があります。 それが不可能な場合の代替案は、ASTの命令セットを定義し、コードを読みやすくするのに十分な糖衣構文を有効にするために、それをフリーモナドに変換することです。 これは複雑に見えるかもしれませんが、コード内で不純性を明示的にするという利点があります。 CommandLineProgram値を見ると、実行時に何か不純なことが起こる可能性があることがわかります。

ただし、これは制御されない不純物ではありません。 CommandLineProgram内では、コマンドラインからの読み取りとコマンドラインへの書き込みのみが行われます。 ランダムな値を生成したり、グローバル変数を変更したり、電子メールを送信したり、その他の予測不可能な操作を実行したりすることはありません。インタープリターがそうしなければ…

次: 純粋なコマンドラインウィザード


インデックスへ戻る