Haskellにおけるフリーモナドの組み合わせ
2025-04-13 16:45:25
原文: Combining free monads in Haskell by Mark Seemann
Haskellにおけるフリーモナドの合成例について解説します。
この純粋なインタラクションに関する連載の前回の記事では、フリーモナドを使って抽象構文木(AST)を作り、F#でコマンドラインウィザードを実装する方法を紹介しました。その例ではレストランの予約情報を集めるだけで、その先の処理は行いませんでした。
より実践的なものにするには、コマンドラインインターフェース(CLI)で予約データを集めるだけでなく、HTTP APIを使って実際に予約を実行できることが望ましいでしょう。そのためには、HTTP APIとのやり取りも別のASTとしてモデル化し、これら2つのAPIを組み合わせて統合APIを作る方法を考える必要があります。
F#でこれをどう実装するかを見つけるために、まずHaskellで試してみました。この記事ではHaskellでの実装方法を紹介し、次の記事でこのHaskellプロトタイプをF#に移植する方法を説明します。こうすることで、F#のコードも同じように機能することを確認できます。
コマンドラインAPI
まずは簡単な部分から始めましょう。前回の記事では、コンピュテーション式による糖衣構文も含めて、コマンドライン操作をASTとしてモデル化する方法を説明しました。F#ではかなりの量のボイラープレートコードが必要でしたが、Haskellなら宣言的に記述できます。
import Control.Monad.Trans.Free (Free, liftF)
data CommandLineInstruction next =
ReadLine (String -> next)
| WriteLine String next
deriving (Functor)
type CommandLineProgram = Free CommandLineInstruction
readLine :: CommandLineProgram String
readLine = liftF (ReadLine id)
writeLine :: String -> CommandLineProgram ()
writeLine s = liftF (WriteLine s ())
これが、HaskellでASTを定義してモナドにするために必要なコードのすべてです。F#で必要だったコード量と比べてみてください!
CommandLineInstruction型は命令セットを定義するもので、DeriveFunctorという言語拡張を使っています。これにより、Haskellはこの型から自動的にFunctorインスタンスを生成できます。
型エイリアスtype CommandLineProgram = Free CommandLineInstructionは、CommandLineInstructionからフリーモナドを作ります。Freeは、元になる型がFunctorであればMonadになります。
readLine値とwriteLine関数は、CommandLineInstructionの命令をCommandLineProgram値に「持ち上げる」便利な関数です。これらはF#でも1行で書けました。
HTTPクライアントAPI
CommandLineProgram APIを使ってレストラン予約データを集める小さなウィザードは作れますが、新たな要件として、HTTPリクエストを実行し、CLIプログラムがバックエンドシステムで実際に予約を行えるようにする必要があります。CommandLineProgramに命令を追加することもできますが、それでは関心の分離がうまくいきません。必要なHTTPリクエストを行うための命令セットは、別に定義する方がよいでしょう。
このAPIは単純なString値より複雑な値を送受信するので、まず関連する型を定義しましょう。
data Slot = Slot {
slotDate :: String,
seatsRemaining :: Int }
deriving (Show)
data Reservation = Reservation {
reservationDate :: String,
name :: String,
email :: String,
quantity :: Int }
deriving (Show)
Slot型は特定の日付の空席数に関する情報、Reservation型は予約に必要な情報を含みます。Reservation型は、前回の記事で紹介したF#のReservationレコード型と似ています。
オンラインレストラン予約のHTTP APIはもっと多機能かもしれませんが、ここでは必要な命令だけをモデル化するのが合理的でしょう。
{-# LANGUAGE DeriveFunctor #-}
module HttpApi where
import Control.Monad.Free
import Data
data ReservationsApiInstruction next =
GetSlots String ([Slot] -> next)
| PostReservation Reservation next
deriving (Functor)
この命令セットは2つのやり取りをモデル化します。GetSlotsケースは、特定の日付の空き状況をHTTP APIに問い合わせる命令、PostReservationケースは、Reservation情報を含むPOSTリクエストを送って予約を実行する命令です。
先ほどのCommandLineInstructionと同じく、この型も(自動的に)Functorとなり、そこからMonadを作ることができます。
type ReservationsApiProgram = Free ReservationsApiInstruction
この場合も、モナドは単なる型エイリアスです。
最後に、通常どおりのリフト関数が必要です。
getSlots :: String -> ReservationsApiProgram [Slot]
getSlots date = liftF $ GetSlots date id
postReservation :: Reservation -> ReservationsApiProgram ()
postReservation r = liftF $ PostReservation r ()
これで、CommandLineProgramとReservationsApiProgramの命令を組み合わせて、より複雑なASTを構築するウィザードを書く準備ができました。
ウィザード
ウィザードには以下の機能が必要です。
- 予約する人数と日付を尋ねる
- 指定された日付の空き状況をHTTP APIに問い合わせる。席が足りなければ終了する
- 席が十分にあれば、名前とメールアドレスを尋ねる
- HTTP APIに予約リクエストを送る
前回のF#の例と同じように、ウィザードの処理の一部をヘルパー関数に切り出せます。最初の関数は、ユーザーに入力を促し、その値を解析するものです。
import Text.Read (readMaybe)
readParse :: Read a => String -> String -> CommandLineProgram a
readParse prompt errorMessage = do
writeLine prompt
l <- readLine
case readMaybe l of
Just x -> return x
Nothing -> do
writeLine errorMessage
readParse prompt errorMessage
まずwriteLineを使ってpromptをコマンドラインに表示します — というより、そうする命令を作ります。この命令は純粋な値であり、インタープリターがASTを評価するまで副作用は起こりません。
次の行ではreadLineを使ってユーザーの入力を読み取ります。readLineはCommandLineProgram String型の値ですが、Haskellのdo記法のおかげでlはString値として扱えます。これでreadMaybeを使ってString値の解析を試せます。readMaybeはMaybe a型の値を返すので、パターンマッチングで処理できます。readMaybeがJust値を返したら、その中身を返します。そうでなければ、errorMessageを表示し、readParseを再帰的に呼び出します。
前回のF#の例と同じく、処理を進めるにはreadMaybeが解析できる値を入力し続けるしかありません。他に終了する方法はありません。本来なら終了オプションも用意すべきですが、このデモでは重要ではありません。
前回のF#の例とは違い、ここでは3度目の法則を破りたい誘惑にかられたかもしれません。Haskellでは再利用可能な関数を定義するのが簡単だからです。ジェネリックな値がRead型クラスのインスタンスである、という制約付きで関数をジェネリックなままにしておけます。
readParse関数はCommandLineProgram a型の値を返します。これはCommandLineProgramとReservationsApiProgramを組み合わせるものではありません。組み合わせは別の関数で行いますが、その前にもう一つ、小さなヘルパー関数が必要です。
readAnything :: String -> CommandLineProgram String
readAnything prompt = do
writeLine prompt
readLine
readAnything関数は、プロンプトを表示してユーザーの入力を読み取り、無条件にその値を返すだけです。readAnything prompt = writeLine prompt >> readLineのように1行でも書けますが、少し冗長でも上の書き方の方が読みやすいでしょう。
これらを使ってウィザードを実装できます。
import Control.Monad.Trans.Free (FreeT, runFreeT, liftF)
import Control.Monad.Trans.Class (lift)
import Data.Time.Calendar (Day)
import Data.Time.Format (parseTimeM, defaultTimeLocale)
tryReserve :: FreeT ReservationsApiProgram CommandLineProgram ()
tryReserve = do
q <- lift $ readParse
"Please enter number of diners:"
"Not an integer. Try again."
d <- lift $ readParse
"Please enter reservation date:"
"Not a date. Try again."
slots <- liftF $ GetSlots d id
let availableSeats = sum $ map seatsRemaining slots
if availableSeats < q
then lift $ writeLine $
"Only " ++ show availableSeats ++
" remaining seats for " ++ d ++ "."
else do
n <- lift $ readAnything "Please enter your name:"
e <- lift $ readAnything "Please enter your email address:"
liftF $ PostReservation (Reservation d n e q) ()
tryReserveプログラムは、まずユーザーに人数と日付を尋ねます。日付dを受け取ったらgetSlotsを呼び出し、空席数の合計を計算します。availableSeatsもqと同じくIntなので、両者を比較できます。空席数が希望の人数より少なければ、プログラムはその旨を表示して終了します。
この一連のやり取りは、CommandLineProgramとReservationsApiProgramの命令を交互に使う様子を示しています。ユーザーにすべての情報を入力してもらった後で席が足りないと分かるのは、ユーザー体験としてよくありません。
一方、席が十分にあれば、ユーザーに名前とメールアドレスを入力してもらい、情報の収集を続けます。すべてのデータが揃ったら、新しいReservation値を作ってpostReservationを呼び出します。
tryReserveの型に注目してください。これはCommandLineProgramとReservationsApiProgramを組み合わせたもので、FreeTという型に格納されています。この型もMonadなので、do記法がそのまま使えます。このことは、コード内でliftとliftFが様々に使われている理由の説明にもなります。
doブロック内で<-を使って「モナドから値を取り出す」場合、矢印の右側は関数全体の戻り値と同じ型でなければなりません。この例では、戻り値の型はFreeT ReservationsApiProgram CommandLineProgram ()ですが、readParseはCommandLineProgram a型の値を返します。例えばliftは、CommandLineProgram IntをFreeT ReservationsApiProgram CommandLineProgram Intに変換します。
tryReserveの型宣言を見ると、CommandLineProgram a型の値にはliftを、ReservationsApiProgram a型の値にはliftFを使っていることがわかります。これはFreeT内のモナドの順序に依存します。もしCommandLineProgramとReservationsApiProgramの順序を入れ替えたら、liftとliftFの使い方も逆になります。
インタープリター
tryReserveは純粋な値です。これは、ユーザー、コマンドライン、HTTPクライアント間の複雑なやり取りを記述するために、2つの異なる命令セットを組み合わせた抽象構文木なのです。プログラムは解釈されるまで何も実行しません。
各APIについて非純粋なインタープリターを書き、それらを使ってtryReserveを解釈する3番目のインタープリターを作ることができます。
CommandLineProgram型の値を解釈する方法は、前回のF#の例と似ています。
interpretCommandLine :: CommandLineProgram a -> IO a
interpretCommandLine (Pure x) = return x
interpretCommandLine (Free (ReadLine next)) = do
line <- getLine
interpretCommandLine (next line)
interpretCommandLine (Free (WriteLine line next)) = do
putStrLn line
interpretCommandLine next
このインタープリターは、任意のCommandLineProgram aに含まれるすべてのケースをパターンマッチングする再帰関数です。Pureケースの場合は、中に含まれる値をそのまま返します。
ReadLine値に出会うと、コマンドラインから入力を読み取るgetLine(IO String型の値を返す)を呼び出しますが、doブロックのおかげでlineはString値として扱えます。インタープリターはnextを引数lineで呼び出し、その戻り値を自分自身に再帰的に渡します。
WriteLineケースも同様です。putStrLn lineでlineをコマンドラインに出力した後、nextをinterpretCommandLineへの入力引数として使います。
Haskellの型システムのおかげで、interpretCommandLineが非純粋であることは明らかです。すべてのCommandLineProgram aに対してIO aを返すからです。これは最初から意図していたことです。
同様に、ReservationsApiProgram型の値を解釈するインタープリターも書くことができます。
interpretReservationsApi :: ReservationsApiProgram a -> IO a
interpretReservationsApi (Pure x) = return x
interpretReservationsApi (Free (GetSlots date next)) = do
slots <- HttpClient.getSlots date
interpretReservationsApi (next slots)
interpretReservationsApi (Free (PostReservation reservation next)) = do
HttpClient.postReservation reservation
interpretReservationsApi next
interpretReservationsApiの構造はinterpretCommandLineと似ています。こちらは、HTTP APIとの非純粋なやり取りを含むHttpClientモジュールに実装を任せています。このモジュールは本記事では示しませんが、記事に付属するGitHubリポジトリで確認できます。
これら2つのインタープリターを使って、組み合わせたインタープリターを作ることができます。
interpretProgram :: FreeT ReservationsApiProgram CommandLineProgram a -> IO a
interpretProgram program = do
cmdLineProg <- runFreeT program
case cmdLineProg of
Pure x -> return x
Free p -> do
y <- interpretReservationsApi p
interpretProgram y
この関数は期待どおりの型を持っています。つまり、任意のFreeT ReservationsApiProgram CommandLineProgram aを評価してIO aを返します。runFreeTは、組み合わせたプログラムのCommandLineProgram部分を返します。この値をinterpretCommandLineに渡すと、元になる型(いわばCommandLineProgram aのa)が得られます。ただし、この場合のaは非常に複雑な型なので、ここでは詳細は省きます。コンテナのレベルで見ると、パターンマッチングに使えるPureまたはFreeのどちらかのケースを含むFreeF値である、と言えば十分でしょう。
Pureケースの場合は処理が完了しているので、元になる値をそのまま返します。
Freeケースの場合は、内部のpはReservationsApiProgram型の値であり、interpretReservationsApiで解釈できます。これはIO a型の値を返し、doブロックによってyはa型の値となります。この場合のaはFreeT ReservationsApiProgram CommandLineProgram aなので、関数は次の命令を解釈するために、yを使って自身を再帰的に呼び出せます。
実行
ASTとインタープリターの両方が揃えば、プログラムの実行は簡単です。
main :: IO ()
main = interpretProgram tryReserve
プログラムを実行すると、例えば次のようなやり取りが行われます。
Please enter number of diners:
4
Please enter reservation date:
2017/8/2
Not a date. Try again.
Please enter reservation date:
2017-08-02
Please enter your name:
Mark Seemann
Please enter your email address:
mark@example.org
最初のほうで日付の形式を間違えたのにお気づきでしょうか。そのため、readParseが再度、入力を促しています。
このコードサンプルをご自身で実行するには、やり取りできる適切なHTTP APIが必要です。私はローカルマシンでAPIをホストし、実行後、レコードが実際に予約データベースに書き込まれたことを確認しました。
まとめ
この概念実証によって、異なるフリーモナドを組み合わせられることが示されました。これが機能し、全体的な仕組みも理解できたので、F#にも移植できるはずです。ただし、F#ではもっと多くのボイラープレートコードが必要になるだろうと予想されます。