Cont(継続)モナド

関数型プログラミングにおいてモナドは難解な概念として悪評(?)が高いものです。 しかし実装されたモナドを利用するだけなら、モナドの中身を理解する必要はなくブラックボックスとして利用可能です。 ここでは、MoNo.RAIL に用意された継続モナドの利用法について説明します。

WPFと継続モナドは関係あるの?

いいえ。

継続モナドはWPFとは直接は何の関係もありません。 しかし、GUIアプリケーションのコマンド実装において、継続モナドは非常に便利に使える場面が多いのです。

MoNo.RAILでは、WPFのコマンドを継続モナドを利用して実装するように設計されています。

継続(continuation)とは?

処理A に継続して処理B を実行することです。

通常、処理Aの後に続けて処理Bを記述すれば、AとBは続けて実行されます。 ここには「継続」や「モナド」などといういかめしい概念を意識する必要などありません。

では次のような場合はどうでしょうか?

  • マウスがクリックされるのを待って(処理A)、続きの処理(処理B)を実行したい。
  • モードレスなダイアログを表示して(処理A)、OKボタンでダイアログが閉じたときに続きの処理(処理B)を実行したい。
  • 重たい計算処理(処理A)を別スレッドで非同期実行し、終了後に続きの処理(処理B)を(UIスレッドで)実行したい。

いずれも「Aの後にBを継続して実行したい」という点で共通しているにも関わらず、それぞれのケースで全く異なるコーディングが要求されます。

マウスクリックの場合は、MouseClick イベントにイベントハンドラを登録し、そのハンドラから処理Bを呼び出す必要があります。

非同期実行の場合は、別スレッドで処理Aの終了後、UIスレッドに切り替えて処理Bを呼び出すことになります。

これらの一見全く異なるように思える処理から、「Aの後にBを継続する」という共通部分を抽象化して取り出したものが「継続モナド」です。

LINQPad で動かしてみよう

準備

LINQPad を立ち上げたら、[Query | References and Properties (F4)] メニューから次の設定をします。

  • Additional Referecnes に MoNo.FSharp.dll を追加
  • Additional Namespace Imports に MoNo を追加

また、Language は “F# Expression” を選択しておきます。

Hello World

cont {
  printfn "Hello World"
}
|> Cont.execute false ignore ignore ignore ignore ignore

これを実行すると出力欄に “Hello World” が表示されます。

cont { ... } の部分が継続モナドを生成する「コンピュテーション式」です。 コンピュテーション式というのは F# の機能の一つですが、とりあえずここでは詳しく知っている必要はありません。 cont { ... } で囲むと、その中で継続モナドが動く、程度の理解でまずは大丈夫です。 (※ コンピュテーション式自体はF#の機能ですが、 cont というコンピュテーション式を定義しているのは MoNo.RAIL です)

|> Cont.execute false ignore ... の部分で、生成された継続モナドを実行しています。 引数がたくさんありますが、ここでは理解する必要はなく、説明は省略します。 実際に MoNo.RAIL 上でアプリケーションを開発するときは、この関数呼び出しはフレームワーク中に埋め込まれているため自分で呼び出す必要はありません。

さて、Hello World が動くことは分かりましたが、このサンプルでは継続モナドの有り難みが分かりません。 次は MouseClick イベントを扱ってみましょう。

MouseClick イベントを取得する

let form = new System.Windows.Forms.Form()
form.Show()

cont {
  let! click = form.MouseClick |> Cont.ofObservable  // (A)
  printfn "(X, Y) = (%d, %d)" click.X click.Y        // (B)
}
|> Cont.execute false ignore ignore ignore ignore ignore

printfn "end"

これを実行すると次のように動作します。

  1. 空のウィンドウ(Form)が表示され、出力欄に “end” が表示されます。
  2. ウィンドウをクリックすると (X, Y) = (131, 180) といった出力が得られます。

まず cont { ... } の中を見て下さい。 細かい点はさておき、(A)でクリックイベントを取得し、(B)でクリックされた座標を出力していることが雰囲気で読み取れると思います。 標準入力を Console.ReadLine() の1行で取得できるとの同様の手軽さでクリックイベントを取得できています。

また、クリック位置の出力よりも先に “end” が出力される点にも注意して下さい。 このコードは単純に上から順番に実行されるわけではないということです。 (A)で MouseClick のイベントハンドラが設定され、(B)はそのイベントハンドラの中で実行されるコードなのです。

(A) の let! に注目して下さい。単なる let ではなく、! が付いています。 これはF#のコンピュテーション式が備えている文法です。 let! (あるいは do! )の箇所で継続モナドの「トリック」が動いています。

次は右辺の form.MouseClick |> Cont.ofObservable に注目しましょう。 F# ではイベントは IObservable<'TEventArgs> オブジェクトとして扱うことが出来ます。 イベントの発火を監視(observe)出来るというわけですね。 Cont.ofObservableIObservable<'T> を継続モナド Cont<'T> に変換する関数です。

右辺の値は Cont<MouseEventArgs> 型です。 一方、左辺の clickMouseEventArgs 型です。 つまり、継続モナドを let! で受けることによって、継続モナドの中にくるまれていた値を外に取り出す(かのように記述する)ことが出来るわけです。

2点クリックで直線を作図する

let form = new System.Windows.Forms.Form()
form.Show()

cont {
  let! click1 = form.MouseClick |> Cont.ofObservable
  printfn "(X1, Y1) = (%d, %d)" click1.X click1.Y

  let! click2 = form.MouseClick |> Cont.ofObservable
  printfn "(X2, Y2) = (%d, %d)" click2.X click2.Y

  let pen = new System.Drawing.Pen(System.Drawing.Color.Black)
  form.Paint |> Observable.add (fun e ->
    e.Graphics.DrawLine (pen, click1.Location, click2.Location))
  form.Invalidate()
}
|> Cont.execute false ignore ignore ignore ignore ignore

printfn "end"

ここまでくれば2点クリックは簡単です。同様の記述を繰り返すだけです。 最後に間を結ぶ直線作図を Paint イベントに登録すれば完成です。

なお、Cont.execute の第一引数は repeat フラグです。 ここに true を設定すると直線作図コマンドが繰り返されるので、直線を何本も作図することが出来るようになります。

ContRunnerの利用

ここまで継続モナドを実行するために Cont.execute に継続モナドを渡してきましたが Cont.Runner を使うことで実行することもできます。

例えば前述の2点クリックで直線を作図するサンプルは以下のように書けます。

let form = new System.Windows.Forms.Form()
form.Show()

let runner = ContRunner()
runner.Run (cont {
        let! click1 = form.MouseClick |> Cont.ofObservable
        printfn "(X1, Y1) = (%d, %d)" click1.X click1.Y

        let! click2 = form.MouseClick |> Cont.ofObservable
        printfn "(X2, Y2) = (%d, %d)" click2.X click2.Y

        let pen = new System.Drawing.Pen(System.Drawing.Color.Black)
        form.Paint |> Observable.add (fun e ->
                e.Graphics.DrawLine (pen, click1.Location, click2.Location))
        form.Invalidate()
})
printfn "end"

なお、 Run メソッドの第2引数にはオプションでrepeatフラグを指定できます。上記のコードのように指定しない場合、デフォルトではfalseです。 trueを明示的に指定すると繰り返し実行できるコマンドとなります。

さらに ByAsync メソッドにasyncコンピューテーション式を渡して継続モナドを作成することもできます。 下記のコードをLINQPadに入力して実行してみて下さい。

let form = new System.Windows.Forms.Form()
form.Show()

let runner = ContRunner()
runner.Run (cont {
        let! click1 = form.MouseClick |> Cont.ofObservable
        printfn "(X1, Y1) = (%d, %d)" click1.X click1.Y

        let! click2 = form.MouseClick |> Cont.ofObservable
        printfn "(X2, Y2) = (%d, %d)" click2.X click2.Y

        let! (p1, p2) = runner.ByAsync( async {
                        do! Async.Sleep 1000
                        return click1.Location, click2.Location
                }
        )

        let pen = new System.Drawing.Pen(System.Drawing.Color.Black)
        form.Paint |> Observable.add (fun e ->
                e.Graphics.DrawLine (pen, p1, p2))
        form.Invalidate()
})
printfn "end"

2点をクリックした後、1秒間待機してから直線が描画されることがわかると思います。