純粋なコマンドラインウィザード
2025-04-13 10:00:25
原文: A pure command-line wizard by Mark Seemann
F#の構文糖衣を活用した小さな抽象構文木の例
前回の記事では、F#における関数型コマンドラインAPIを紹介しました。しかし、その例はあまりにも単純で、APIの構成力を十分に示せませんでした。今回は、より具体的な例を見ていきましょう。
オンラインレストラン予約システムのコマンドラインウィザード
過去の記事では、オンラインレストラン予約システム向けのHTTPベースのバックエンドをいくつか紹介しました。一方、今回はそのAPIのコマンドラインクライアントの第一歩を紹介します。
通常、オンラインレストラン予約システムはWebページやモバイルアプリのGUIを使いますが、オープンなHTTP APIがあれば、本物のギークならコマンドラインインターフェース(CLI)を好むはず…ですよね?!
Please enter number of diners:
four
Not an integer.
Please enter number of diners:
4
Please enter your desired date:
My next birthday
Not a date.
Please enter your desired date:
2017-11-25
Please enter your name:
Mark Seemann
Please enter your email address:
mark@example.com
{Date = 25.11.2017 00:00:00 +01:00;
Name = "Mark Seemann";
Email = "mark@example.com";
Quantity = 4;}
この実装では、CLIは情報を集めてF#レコードの表現をコマンドラインに表示するだけです。将来の記事では、これをHTTPクライアントと連携させて、バックエンドシステムに予約を登録する方法を紹介する予定です。
このCLIはウィザード形式になっています。一連の質問であなたを導きます。次の質問に進むには、各質問に適切に回答する必要があります。たとえば、人数には整数を入力しなければなりません。整数以外を入力すると、正しく入力するまでウィザードは同じ質問を繰り返します。
このようなインターフェースは、前回の記事で紹介したcommandLineコンピュテーション式を使って開発できます。
数量を読み取る
ウィザードには4つのステップがあります。最初のステップは、コマンドラインから希望する人数を読み取ることです:
// CommandLineProgram<int>
let rec readQuantity = commandLine {
do! CommandLine.writeLine "Please enter number of diners:"
let! l = CommandLine.readLine
match Int32.TryParse l with
| true, dinerCount -> return dinerCount
| _ ->
do! CommandLine.writeLine "Not an integer."
return! readQuantity }
このシンプルなインタラクションは、commandLine式の中ですべて定義されています。これにより、do!式とlet!バインディングを使って、CommandLine.writeLineやCommandLine.readLine(前回の記事で紹介)などの小さなCommandLineProgram値を組み合わせることができます。
ユーザーに数字の入力を促した後、プログラムはコマンドラインからユーザーの入力を読み取ります。CommandLine.readLineはCommandLineProgram<string>型の値ですが、let!バインディングによってlはstring値になります。入力が整数として解析できれば、その整数を返します。そうでなければ、readQuantityを再帰的に呼び出します。
readQuantityプログラムは、ユーザーが整数を入力するまで促し続けます。ウィザードをキャンセルする選択肢はありません。これは例をシンプルに保つための意図的な簡略化ですが、実用的なプログラムではウィザードを中止する選択肢も提供すべきでしょう。
この関数はCommandLineProgram<int>値を返します。これは実行すべきインタラクションを記述する抽象構文木(AST)を含む純粋な値です。解釈されるまで何も実行されません。依存関係の注入やインターフェースを使った設計と違い、型情報から、コードベースのこの部分で明示的に区切られた副作用や非決定的な振る舞いが発生する可能性があることがすぐにわかります。
日付を読み取る
適切な人数を入力すると、次は日付の入力に進みます。そのためのプログラムもreadQuantityと似ています:
// CommandLineProgram<DateTimeOffset>
let rec readDate = commandLine {
do! CommandLine.writeLine "Please enter your desired date:"
let! l = CommandLine.readLine
match DateTimeOffset.TryParse l with
| true, dt -> return dt
| _ ->
do! CommandLine.writeLine "Not a date."
return! readDate }
readDateはreadQuantityとよく似ているため、両方を一つの再利用可能な関数にリファクタリングしたくなるかもしれません。しかし今回は、3度目の法則に従うことにしました。
文字列を読み取る
コマンドラインから顧客の名前とメールアドレスを読み取るのは簡単です。特に解析は必要ありません:
// CommandLineProgram<string>
let readName = commandLine {
do! CommandLine.writeLine "Please enter your name:"
return! CommandLine.readLine }
// CommandLineProgram<string>
let readEmail = commandLine {
do! CommandLine.writeLine "Please enter your email address:"
return! CommandLine.readLine }
これらの値は、プロンプトで入力された内容を無条件に受け入れます。セキュリティの観点からは「入力はすべて危険」なので、本番環境のコードではなんらかの検証を行うべきです。ただ、これはデモコードなので、その点に注意した上で、入力されるすべての文字列をそのまま受け入れています。
これらの値は互いによく似ていますが、ここでも3度目の法則を適用して、別々の値として維持しています。
ウィザードの構成
汎用コマンドラインAPIと上記の値を使えば、ウィザードを簡単に構成できます。今回の実装では、ウィザードは入力された情報を集めて、それらの値を持つ単一のレコードを作成します。作成すべきレコードの型は次のとおりです:
type Reservation = {
Date : DateTimeOffset
Name : string
Email : string
Quantity : int }
このようにして、ウィザードを簡単に構成できます:
// CommandLineProgram<Reservation>
let readReservationRequest = commandLine {
let! count = readQuantity
let! date = readDate
let! name = readName
let! email = readEmail
return { Date = date; Name = name; Email = email; Quantity = count } }
特に難しいことはありません。この記事の他のコード例と同様に、commandLine式の中でreadReservationRequest値を完全に構成します。let!バインディングを使って必要な4つのデータ要素を集め、すべて揃ったらReservation値を返します。
プログラムの実行
これまで紹介したコードはすべて値であり、関数は一つも定義していないことにお気づきでしょうか。これらは小さなプログラムの断片であり、ASTとして表現され、より大きなプログラム(それ自体もAST)に組み合わされています。ここまでのコードはすべて純粋です。
プログラムを実行するには、インタープリタが必要です。main関数を作成するとき、前回の記事のインタープリタを再利用できます:
[<EntryPoint>]
let main _ =
Wizard.readReservationRequest
|> CommandLine.bind (CommandLine.writeLine << (sprintf "%A"))
|> interpret
0 // return an integer exit code
ほとんどの動作はWizard.readReservationRequest値によって定義されていることに注目してください。このプログラムはReservation値を返しますが、それをコマンドラインに表示するためにCommandLineモジュールも使用します。CommandLine.bindを使ってWizard.readReservationRequestとCommandLine.writeLineを組み合わせることでこれを実現しています。同じ組み合わせをcommandLineコンピュテーション式で書くこともできますが、この場合は小さな関数パイプラインの方が読みやすいと思います。
2つのCommandLineProgram値を互いにバインドすると、結果は3つ目のCommandLineProgramになります。それをinterpretにパイプすることでプログラムを実行できます。結果は、この記事の冒頭で示したような対話になります。
まとめ
この記事では、F#コンピュテーション式が提供する構文糖衣を使って、小さなASTからより大きなASTを作成する方法を見てきました。ここまでのポイントは、「普通の」F#開発体験を維持しながら、副作用や非決定的な振る舞いを明示的にできるということです。
Haskellでは、不純なコードはIOコンテキスト内で実行できますが、IOの中ではどんな種類の副作用や非決定的な振る舞いも起こりうるのです。そのため、Haskellでも、明示的に区切られた不純な操作のセットを定義することがよく行われます。前回の記事では、Freeを使ってコマンドライン命令のAST型を定義する小さなHaskellコードを紹介しました。コードを読む側としてCommandLineProgram String型の値に出会うと、IO String型の値に出会うよりも、起こりうる副作用について多くのことがわかります。同じ考え方が、条件はありますが、F#にも当てはまります。
CommandLineProgram<Reservation>型の値に出会うと、どのような副作用が予想されるかがわかります:プログラムはコマンドラインへの書き込みか、コマンドラインからの読み取りしか行いません。では、これらの特定の対話を他の種類の対話と組み合わせたい場合はどうすればよいでしょうか?
続きをご覧ください。