この投稿は2015年英語版F#アドベントカレンダープロジェクトの一部です。 他の素晴らしい投稿もぜひチェックしてください!また、このプロジェクトを企画してくれたSergey Tihonに特別な感謝を。

この2部構成の大型投稿では、シンプルなタートルグラフィックスモデルを極限まで拡張しながら、部分適用、バリデーション、「リフティング」の概念、 メッセージキューを持つエージェント、依存性注入、Stateモナド、イベントソーシング、ストリーム処理、そしてインタープリターを実演します!

前回の投稿では、タートルを見る最初の9つの方法を紹介しました。今回は残りの4つを見ていきます。

おさらいとして、13の方法を挙げておきます:

拡大版には、おまけの方法が2つあります。

タートルの上にタートル、その上にまたタートル!

この投稿のすべてのソースコードはGitHubで入手できます。


10: イベントソーシング - 過去のイベントのリストから状態を構築する

このデザインでは、エージェント(方法5)バッチ(方法9)アプローチで使用した「コマンド」の概念を基に、 状態を更新する方法として「コマンド」を「イベント」に置き換えます。

動作の仕組みは次のとおりです:

  • クライアントがCommandCommandHandlerに送ります。
  • CommandHandlerは、Commandを処理する前に、 まずその特定のタートルに関連する過去のイベントを使って現在の状態をゼロから再構築します。
  • CommandHandlerはコマンドを検証し、現在の(再構築された)状態に基づいて何をするかを決めます。 (場合によっては空の)イベントのリストを生成します。
  • 生成されたイベントは、次のコマンドで使うためにEventStoreに保存されます。

このようにして、クライアントもコマンドハンドラも状態を追跡する必要がありません。EventStoreだけが可変です。

CommandとEvent型

イベントソーシングシステムに関連する型の定義から始めましょう。まず、コマンドに関連する型です:

type TurtleId = System.Guid

/// タートルに対する望ましいアクション
type TurtleCommandAction = 
    | Move of Distance 
    | Turn of Angle
    | PenUp 
    | PenDown 
    | SetColor of PenColor

/// 特定のタートルに向けられた望ましいアクションを表すコマンド
type TurtleCommand = {
    turtleId : TurtleId
    action : TurtleCommandAction 
    }

コマンドはTurtleIdを使って特定のタートルに向けられています。

次に、コマンドから生成される2種類のイベントを定義します:

  • 状態の変化を表すStateChangedEvent
  • タートルの動きの開始位置と終了位置を表すMovedEvent
/// 発生した状態変化を表すイベント
type StateChangedEvent = 
    | Moved of Distance 
    | Turned of Angle
    | PenWentUp 
    | PenWentDown 
    | ColorChanged of PenColor

/// 発生した移動を表すイベント
/// これはキャンバス上の線描画アクティビティに簡単に変換できます
type MovedEvent = {
    startPos : Position 
    endPos : Position 
    penColor : PenColor option
    }

/// 可能なすべてのイベントの共用体
type TurtleEvent = 
    | StateChangedEvent of StateChangedEvent
    | MovedEvent of MovedEvent

イベントソーシングの重要な部分として、すべてのイベントは過去形でラベル付けされています:MoveTurnではなくMovedTurnedです。イベントは事実です - 過去に起こったことを表します。

コマンドハンドラ

次のステップは、コマンドをイベントに変換する関数を定義することです。

以下が必要になります:

  • 以前のイベントから状態を更新する(プライベートな)applyEvent関数。
  • コマンドと状態に基づいて、生成するイベントを決める(プライベートな)eventsFromCommand関数。
  • コマンドを処理し、イベントストアからイベントを読み取り、他の2つの関数を呼び出す公開commandHandler関数。

これがapplyEventです。以前のバッチ処理の例で見たapplyCommand関数とよく似ています。

/// 現在の状態にイベントを適用し、タートルの新しい状態を返す
let applyEvent log oldState event =
    match event with
    | Moved distance ->
        Turtle.move log distance oldState 
    | Turned angle ->
        Turtle.turn log angle oldState 
    | PenWentUp ->
        Turtle.penUp log oldState 
    | PenWentDown ->
        Turtle.penDown log oldState 
    | ColorChanged color ->
        Turtle.setColor log color oldState

eventsFromCommand関数には、コマンドを検証してイベントを作成するための主要なロジックが含まれています。

  • このデザインでは、コマンドは常に有効なので、少なくとも1つのイベントが返されます。
  • StateChangedEventTurtleCommandから、ケースの一対一のマッピングで直接作成されます。
  • MovedEventは、タートルが位置を変更した場合にのみTurtleCommandから作成されます。
// コマンドと状態に基づいて、生成するイベントを決める
let eventsFromCommand log command stateBeforeCommand =

    // --------------------------
    // TurtleCommandからStateChangedEventを作成する
    let stateChangedEvent = 
        match command.action with
        | Move dist -> Moved dist
        | Turn angle -> Turned angle
        | PenUp -> PenWentUp 
        | PenDown -> PenWentDown 
        | SetColor color -> ColorChanged color

    // --------------------------
    // 新しいイベントから現在の状態を計算する
    let stateAfterCommand = 
        applyEvent log stateBeforeCommand stateChangedEvent

    // --------------------------
    // MovedEventを作成する 
    let startPos = stateBeforeCommand.position 
    let endPos = stateAfterCommand.position 
    let penColor = 
        if stateBeforeCommand.penState=Down then
            Some stateBeforeCommand.color
        else
            None                        

    let movedEvent = {
        startPos = startPos 
        endPos = endPos 
        penColor = penColor
        }

    // --------------------------
    // イベントのリストを返す
    if startPos <> endPos then
        // タートルが移動した場合、stateChangedEventとmovedEventの両方を
        // 共通のTurtleEvent型にリフトして返す
        [ StateChangedEvent stateChangedEvent; MovedEvent movedEvent]                
    else
        // タートルが移動していない場合、stateChangedEventのみを返す
        [ StateChangedEvent stateChangedEvent]

最後に、commandHandlerが公開インターフェースです。これにはいくつかの依存関係がパラメータとして渡されます:ロギング関数、イベントストアから履歴イベントを取得する関数、 新しく生成されたイベントをイベントストアに保存する関数です。

/// タートルIDのStateChangedEventsを取得する関数を表す型
/// 最も古いイベントが最初に来る
type GetStateChangedEventsForId =
     TurtleId -> StateChangedEvent list

/// TurtleEventを保存する関数を表す型
type SaveTurtleEvent = 
    TurtleId -> TurtleEvent -> unit

/// メイン関数:コマンドを処理する
let commandHandler 
    (log:string -> unit) 
    (getEvents:GetStateChangedEventsForId) 
    (saveEvent:SaveTurtleEvent) 
    (command:TurtleCommand) =

    /// まずイベントストアからすべてのイベントを読み込む
    let eventHistory = 
        getEvents command.turtleId

    /// 次に、コマンド前の状態を再作成する
    let stateBeforeCommand = 
        let nolog = ignore // 状態再作成時にはログを取らない
        eventHistory 
        |> List.fold (applyEvent nolog) Turtle.initialTurtleState

    /// コマンドとstateBeforeCommandからイベントを構築する
    /// この部分では提供されたロガーを使う
    let events = eventsFromCommand log command stateBeforeCommand 

    // イベントをイベントストアに保存する
    events |> List.iter (saveEvent command.turtleId)

コマンドハンドラの呼び出し

これでイベントをコマンドハンドラに送信する準備ができました。

まず、コマンドを作成するヘルパー関数が必要です:

// 標準アクションのコマンドバージョン   
let turtleId = System.Guid.NewGuid()
let move dist = {turtleId=turtleId; action=Move dist} 
let turn angle = {turtleId=turtleId; action=Turn angle} 
let penDown = {turtleId=turtleId; action=PenDown} 
let penUp = {turtleId=turtleId; action=PenUp} 
let setColor color = {turtleId=turtleId; action=SetColor color}

そして、様々なコマンドをコマンドハンドラに送信して図形を描くことができます:

let drawTriangle() = 
    let handler = makeCommandHandler()
    handler (move 100.0)
    handler (turn 120.0<Degrees>)
    handler (move 100.0)
    handler (turn 120.0<Degrees>)
    handler (move 100.0)
    handler (turn 120.0<Degrees>)

注:コマンドハンドラやイベントストアの作成方法は示していません。詳細はコードを参照してください。

イベントソーシングの利点と欠点

利点

  • すべてのコードがステートレスなので、テストが容易です。
  • イベントの再生をサポートします。

欠点

  • CRUDアプローチよりも実装が複雑になる可能性があります(少なくとも、ツールやライブラリのサポートが少ないです)。
  • 注意しないと、コマンドハンドラが過度に複雑になり、多くのビジネスロジックを実装してしまう可能性があります。

このバージョンのソースコードはこちらで入手できます。


11: 関数型リアクティブプログラミング(ストリーム処理)

上記のイベントソーシングの例では、すべてのドメインロジック(この場合は単に状態をトレースするだけ)がコマンドハンドラに組み込まれています。これの欠点の1つは、 アプリケーションが進化するにつれて、コマンドハンドラのロジックが非常に複雑になる可能性があることです。

これを回避する方法の1つは、「関数型リアクティブプログラミング」とイベントソーシングを組み合わせて、 イベントストアから発信されるイベント("シグナル")をリッスンすることで、"読み取り側"でドメインロジックを実行するデザインを作成することです。

このアプローチでは、"書き込み側"はイベントソーシングの例と同じパターンに従います。 クライアントがCommandcommandHandlerに送信し、それをイベントのリストに変換してEventStoreに保存します。

しかし、commandHandlerは最小限の作業(状態の更新など)しか行わず、複雑なドメインロジックは実行しません。 複雑なロジックは、イベントストリームをサブスクライブする1つ以上のダウンストリーム"プロセッサ"("アグリゲータ"とも呼ばれます)によって実行されます。

これらのイベントをプロセッサへの"コマンド"と考えることもでき、もちろん、プロセッサは別のプロセッサが消費する新しいイベントを生成できるので、 このアプローチは、アプリケーションがイベントストアによってリンクされたコマンドハンドラのセットで構成されるアーキテクチャスタイルに拡張できます。

この手法は「ストリーム処理」とよく呼ばれます。 しかし、Jessica Kerrはこのアプローチを「関数型レトロアクティブプログラミング」と呼んでいました - 気に入ったので、この名前を借用します!

デザインの実装

この実装では、commandHandler関数はイベントソーシングの例と同じですが、作業(ただのログ記録!)がまったく行われません。コマンドハンドラは状態を再構築し、 イベントを生成するだけです。イベントをビジネスロジックにどう使うかは、もはやその範囲外です。

新しい部分はプロセッサの作成です。

しかし、プロセッサを作成する前に、イベントストアのフィードをフィルタリングして、タートル固有のイベントのみを含め、 そのうちStateChangedEventまたはMovedEventのみを選択するヘルパー関数が必要です。

// TurtleEventのみを選択するフィルター
let turtleFilter ev = 
    match box ev with
    | :? TurtleEvent as tev -> Some tev
    | _ -> None

// TurtleEventからMovedEventのみを選択するフィルター
let moveFilter = function 
    | MovedEvent ev -> Some ev
    | _ -> None

// TurtleEventからStateChangedEventのみを選択するフィルター
let stateChangedEventFilter = function 
    | StateChangedEvent ev -> Some ev
    | _ -> None

では、移動イベントをリッスンし、仮想タートルが移動したときに物理的なタートルを動かすプロセッサを作成しましょう。

入力をプロセッサにIObservable(イベントストリーム)にして、EventStoreなどの特定のソースに結合しないようにします。 アプリケーションの設定時にEventStoreの "save" イベントをこのプロセッサに接続します。

/// 物理的にタートルを動かす
let physicalTurtleProcessor (eventStream:IObservable<Guid*obj>) =

    // オブザーバブルからの入力を処理する関数
    let subscriberFn (ev:MovedEvent) =
        let colorText = 
            match ev.penColor with
            | Some color -> sprintf "%A色の線" color
            | None -> "線なし"
        printfn "[タートル]: (%0.2f,%0.2f)から(%0.2f,%0.2f)に%sで移動" 
            ev.startPos.x ev.startPos.y ev.endPos.x ev.endPos.y colorText 

    // すべてのイベントから始める
    eventStream
    // ストリームをTurtleEventだけにフィルタリング
    |> Observable.choose (function (id,ev) -> turtleFilter ev)
    // MovedEventだけにフィルタリング
    |> Observable.choose moveFilter
    // これらを処理
    |> Observable.subscribe subscriberFn

この場合、単に移動を出力しているだけです - 実際のレゴマインドストームタートルの構築は読者の課題としておきます!

グラフィックスディスプレイに線を描くプロセッサも作成しましょう:

/// グラフィックスデバイスに線を描く
let graphicsProcessor (eventStream:IObservable<Guid*obj>) =

    // オブザーバブルからの入力を処理する関数
    let subscriberFn (ev:MovedEvent) =
        match ev.penColor with
        | Some color -> 
            printfn "[グラフィックス]: (%0.2f,%0.2f)から(%0.2f,%0.2f)に%A色で線を描く" 
                ev.startPos.x ev.startPos.y ev.endPos.x ev.endPos.y color
        | None -> 
            ()  // 何もしない

    // すべてのイベントから始める
    eventStream
    // ストリームをTurtleEventだけにフィルタリング
    |> Observable.choose (function (id,ev) -> turtleFilter ev)
    // MovedEventだけにフィルタリング
    |> Observable.choose moveFilter
    // これらを処理
    |> Observable.subscribe subscriberFn

最後に、移動した総距離を累積して、使用したインクの量を追跡するプロセッサを作成しましょう。

/// "moved"イベントをリッスンし、それらを集計して
/// 使用したインクの総量を追跡する
let inkUsedProcessor (eventStream:IObservable<Guid*obj>) =

    // 新しいイベントが発生したときに、これまでの移動距離の合計を累積する
    let accumulate distanceSoFar (ev:StateChangedEvent) =
        match ev with
        | Moved dist -> 
            distanceSoFar + dist 
        | _ -> 
            distanceSoFar 

    // オブザーバブルからの入力を処理する関数
    let subscriberFn distanceSoFar  =
        printfn "[使用インク]: %0.2f" distanceSoFar  

    // すべてのイベントから始める
    eventStream
    // ストリームをTurtleEventだけにフィルタリング
    |> Observable.choose (function (id,ev) -> turtleFilter ev)
    // StateChangedEventだけにフィルタリング
    |> Observable.choose stateChangedEventFilter
    // 総距離を累積
    |> Observable.scan accumulate 0.0
    // これらを処理
    |> Observable.subscribe subscriberFn

このプロセッサはObservable.scanを使って、イベントを単一の値(移動した総距離)に累積しています。

プロセッサの実践

これらを試してみましょう!

たとえば、drawTriangleはこのようになります:

let drawTriangle() = 
    // 古いイベントをクリア
    eventStore.Clear turtleId   

    // IEventからイベントストリームを作成
    let eventStream = eventStore.SaveEvent :> IObservable<Guid*obj>

    // プロセッサを登録
    use physicalTurtleProcessor = EventProcessors.physicalTurtleProcessor eventStream 
    use graphicsProcessor = EventProcessors.graphicsProcessor eventStream 
    use inkUsedProcessor = EventProcessors.inkUsedProcessor eventStream 

    let handler = makeCommandHandler
    handler (move 100.0)
    handler (turn 120.0<Degrees>)
    handler (move 100.0)
    handler (turn 120.0<Degrees>)
    handler (move 100.0)
    handler (turn 120.0<Degrees>)

eventStore.SaveEventがプロセッサにパラメータとして渡される前にIObservable<Guid*obj>(つまりイベントストリーム)にキャストされていることに注意してください。

drawTriangleは以下の出力を生成します:

[使用インク]: 100.00
[タートル  ]: (0.00,0.00)から(100.00,0.00)に黒色の線で移動
[グラフィックス]: (0.00,0.00)から(100.00,0.00)に黒色で線を描く
[使用インク]: 100.00
[使用インク]: 200.00
[タートル  ]: (100.00,0.00)から(50.00,86.60)に黒色の線で移動
[グラフィックス]: (100.00,0.00)から(50.00,86.60)に黒色で線を描く
[使用インク]: 200.00
[使用インク]: 300.00
[タートル  ]: (50.00,86.60)から(0.00,0.00)に黒色の線で移動
[グラフィックス]: (50.00,86.60)から(0.00,0.00)に黒色で線を描く
[使用インク]: 300.00

すべてのプロセッサがイベントを正常に処理していることがわかります。

タートルは移動し、グラフィックスプロセッサは線を描き、インク使用プロセッサは移動した総距離を正しく300単位と計算しています。

ただし、インク使用プロセッサは実際の移動時だけでなく、すべての状態変化(回転など)で出力を発生させていることに注意してください。

これを修正するには、ストリームに(前回の距離, 現在の距離)のペアを入れ、値が同じイベントをフィルタリングで除外します。

新しいinkUsedProcessorのコードを以下に示します。変更点は:

  • accumulate関数がペアを出力するようになりました。
  • 新しいフィルターchangedDistanceOnlyを追加しました。
/// "moved"イベントをリッスンし、それらを集計して
/// 移動した総距離を追跡する
/// 新機能!重複イベントなし! 
let inkUsedProcessor (eventStream:IObservable<Guid*obj>) =

    // 新しいイベントが発生したときに、これまでの移動距離の合計を累積する
    let accumulate (prevDist,currDist) (ev:StateChangedEvent) =
        let newDist =
            match ev with
            | Moved dist -> 
                currDist + dist
            | _ -> 
                currDist
        (currDist, newDist)

    // 変更のないイベントをNoneに変換し、"choose"でフィルタリングできるようにする
    let changedDistanceOnly (currDist, newDist) =
        if currDist <> newDist then 
            Some newDist 
        else 
            None

    // オブザーバブルからの入力を処理する関数
    let subscriberFn distanceSoFar  =
        printfn "[使用インク]: %0.2f" distanceSoFar  

    // すべてのイベントから始める
    eventStream
    // ストリームをTurtleEventだけにフィルタリング
    |> Observable.choose (function (id,ev) -> turtleFilter ev)
    // StateChangedEventだけにフィルタリング
    |> Observable.choose stateChangedEventFilter
    // 新機能!総距離をペアとして累積
    |> Observable.scan accumulate (0.0,0.0)   
    // 新機能!距離が変化していない場合はフィルタリング
    |> Observable.choose changedDistanceOnly
    // これらを処理
    |> Observable.subscribe subscriberFn

これらの変更により、drawTriangleの出力は以下のようになります:

[使用インク]: 100.00
[タートル  ]: (0.00,0.00)から(100.00,0.00)に黒色の線で移動
[グラフィックス]: (0.00,0.00)から(100.00,0.00)に黒色で線を描く
[使用インク]: 200.00
[タートル  ]: (100.00,0.00)から(50.00,86.60)に黒色の線で移動
[グラフィックス]: (100.00,0.00)から(50.00,86.60)に黒色で線を描く
[使用インク]: 300.00
[タートル  ]: (50.00,86.60)から(0.00,0.00)に黒色の線で移動
[グラフィックス]: (50.00,86.60)から(0.00,0.00)に黒色で線を描く

これでinkUsedProcessorからの重複メッセージはなくなりました。

ストリーム処理の利点と欠点

利点

  • イベントソーシングと同じ利点があります。
  • 状態を持つロジックを、他の本質的でないロジックから分離します。
  • コアのコマンドハンドラに影響を与えずに、ドメインロジックの追加と削除が容易です。

欠点

  • 実装がより複雑になります。

このバージョンのソースコードはこちらで入手できます。


エピソードV:タートルの逆襲

これまで、タートルの状態に基づいて決定を下す必要はありませんでした。そこで、最後の2つのアプローチでは、 一部のコマンドが失敗する可能性があるようにタートルAPIを変更します。

たとえば、タートルが限られたアリーナ内で移動しなければならず、move命令によってタートルが障壁に衝突する可能性があるとしましょう。 この場合、move命令はMovedOkHitBarrierの選択肢を返すことができます。

または、色付きのインクの量が限られているとしましょう。この場合、色を設定しようとすると「インク切れ」の応答が返される可能性があります。

では、これらのケースでタートル関数を更新しましょう。まず、movesetColorの新しい応答型です:

type MoveResponse = 
    | MoveOk 
    | HitABarrier

type SetColorResponse = 
    | ColorOk
    | OutOfInk

タートルがアリーナ内にいるかどうかを確認する境界チェッカーが必要です。 位置が正方形(0,0,100,100)の外に出ようとすると、応答はHitABarrierになるとしましょう:

// 位置が正方形(0,0,100,100)の外にある場合
// 位置を制限してHitBarrierを返す
let checkPosition position =
    let isOutOfBounds p = 
        p > 100.0 || p < 0.0
    let bringInsideBounds p = 
        max (min p 100.0) 0.0

    if isOutOfBounds position.x || isOutOfBounds position.y then
        let newPos = {
            x = bringInsideBounds position.x 
            y = bringInsideBounds position.y }
        HitABarrier,newPos
    else
        MoveOk,position

最後に、move関数に新しい位置をチェックする行を追加する必要があります:

let move log distance state =
    let newPosition = ...

    // 範囲外の場合、新しい位置を調整
    let moveResult, newPosition = checkPosition newPosition 

    ...

これが完全なmove関数です:

let move log distance state =
    log (sprintf "Move %0.1f" distance)
    // 新しい位置を計算 
    let newPosition = calcNewPosition distance state.angle state.position 
    // 範囲外の場合、新しい位置を調整
    let moveResult, newPosition = checkPosition newPosition 
    // 必要な場合、線を描く
    if state.penState = Down then
        dummyDrawLine log state.position newPosition state.color
    // 新しい状態とMoveの結果を返す
    let newState = {state with position = newPosition}
    (moveResult,newState)

setColor関数にも同様の変更を加え、色をRedに設定しようとするとOutOfInkを返すようにします。

let setColor log color state =
    let colorResult = 
        if color = Red then OutOfInk else ColorOk
    log (sprintf "SetColor %A" color)
    // 新しい状態とSetColorの結果を返す
    let newState = {state with color = color}
    (colorResult,newState)

タートル関数の新バージョンが利用可能になったので、エラーケースに対応する実装を作成する必要があります。これは次の2つの例で行います。

新しいタートル関数のソースコードはこちらで入手できます。


12: モナディック制御フロー

このアプローチでは、方法8turtleワークフローを再利用します。 ただし今回は、前のコマンドの結果に基づいて次のコマンドの決定を行います。

その前に、moveの変更がコードにどのような影響を与えるか見てみましょう。たとえば、move 40.0を使って何回か前進したいとします。

以前のようにdo!を使ってコードを書くと、厄介なコンパイラエラーが発生します:

let drawShape() = 
    // 一連の指示を定義 
    let t = turtle {
        do! move 60.0   
        // エラー FS0001: 
        // この式は以下の型を持つと期待されていました
        //    Turtle.MoveResponse    
        // しかし、ここでは以下の型を持っています
        //     unit    
        do! move 60.0 
        } 
    // 以下省略

代わりに、let!を使用し、応答を何かに割り当てる必要があります。

以下のコードでは、応答を値に割り当てて、それを無視しています!

let drawShapeWithoutResponding() = 
    // 一連の指示を定義 
    let t = turtle {
        let! response = move 60.0 
        let! response = move 60.0 
        let! response = move 60.0 
        return ()
        } 

    // 最後に、初期状態を使用してモナドを実行
    runT t initialTurtleState

コードはコンパイルされ動作しますが、実行すると、3回目の呼び出しでタートルが壁(100,0)にぶつかって動かなくなっていることが出力からわかります。

Move 60.0
...Draw line from (0.0,0.0) to (60.0,0.0) using Black
Move 60.0
...Draw line from (60.0,0.0) to (100.0,0.0) using Black
Move 60.0
...Draw line from (100.0,0.0) to (100.0,0.0) using Black

応答に基づく決定

HitBarrierを返すmoveへの応答として、90度回転して次のコマンドを待つことにしましょう。あまり賢明なアルゴリズムではありませんが、デモンストレーションには十分でしょう!

これを実装する関数を設計しましょう。入力はMoveResponseですが、出力は何でしょうか? turnアクションを何らかの形でエンコードしたいのですが、 生のturn関数には私たちが持っていない状態の入力が必要です。そこで、状態が利用可能になったとき(runコマンドで)に実行したい指示を表すturtleワークフローを返すことにしましょう。

以下がコードです:

let handleMoveResponse moveResponse = turtle {
    match moveResponse with
    | Turtle.MoveOk -> 
        () // 何もしない
    | Turtle.HitBarrier ->
        // 再試行の前に90度回転
        printfn "おっと -- 障壁にぶつかりました -- 回転します"
        do! turn 90.0<Degrees>
    }

型シグネチャは以下のようになります:

val handleMoveResponse : MoveResponse -> TurtleStateComputation<unit>

これはモナディック(または「対角」)関数です ―― 通常の世界で始まり、TurtleStateComputation世界で終わります。

これらは、「bind」を使用したり、コンピュテーション式内でlet!do!を使用したりできる関数です。

これで、タートルワークフロー内のmoveの後に、このhandleMoveResponseステップを追加できます:

let drawShape() = 
    // 一連の指示を定義 
    let t = turtle {
        let! response = move 60.0 
        do! handleMoveResponse response 

        let! response = move 60.0 
        do! handleMoveResponse response 

        let! response = move 60.0 
        do! handleMoveResponse response 
        } 

    // 最後に、初期状態を使用してモナドを実行
    runT t initialTurtleState

実行結果は以下のようになります:

Move 60.0
...Draw line from (0.0,0.0) to (60.0,0.0) using Black
Move 60.0
...Draw line from (60.0,0.0) to (100.0,0.0) using Black
おっと -- 障壁にぶつかりました -- 回転します
Turn 90.0
Move 60.0
...Draw line from (100.0,0.0) to (100.0,60.0) using Black

移動応答が機能していることがわかります。タートルが(100,0)の端にぶつかったとき、90度回転し、次の移動は成功しました((100,0)から(100,60)へ)。

これで完了です!このコードは、舞台裏で状態が受け渡されている間に、turtleワークフロー内で決定を下せることを示しています。

利点と欠点

利点

  • コンピュテーション式を使用することで、コードはロジックに焦点を当て、「配管」(この場合はタートルの状態)の処理を行うことができます。

欠点

  • 特定のタートル関数の実装にまだ結びついています。
  • コンピュテーション式の実装は複雑になる可能性があり、初心者にとってはその動作が明白ではありません。

このバージョンのソースコードはこちらで入手できます。


13: タートルインタープリター

最後のアプローチでは、タートルのプログラミングとその解釈を完全に分離する方法を見ていきます。

これはコマンドオブジェクトを使ったバッチ処理アプローチに似ていますが、 コマンドの出力に応答できるように拡張されています。

インタープリターの設計

我々が取るアプローチは、一連のタートルコマンドのための「インタープリター」を設計することです。クライアントがタートルにコマンドを提供し、 タートルからの出力に応答しますが、実際のタートル関数は後で特定の実装によって提供されます。

言い換えれば、以下のような一連の交互のコマンドとタートル関数があります:

では、このデザインをコードでどのようにモデル化できるでしょうか?

まず最初の試みとして、このチェーンをリクエスト/レスポンスのペアの連続としてモデル化してみましょう。タートルにコマンドを送信すると、 MoveResponseなどで適切に応答します:

// タートルに送信するもの
type TurtleCommand = 
    | Move of Distance 
    | Turn of Angle
    | PenUp
    | PenDown
    | SetColor of PenColor

// ... そしてタートルはこれらのうちの1つで応答する
type TurtleResponse = 
    | Moved of MoveResponse
    | Turned 
    | PenWentUp
    | PenWentDown
    | ColorSet of SetColorResponse

問題は、応答がコマンドと正しく一致することを保証できないことです。たとえば、Moveコマンドを送信した場合、MoveResponseを期待し、 決してSetColorResponseを期待しません。しかし、この実装ではそれを強制していません!

不正な状態を表現不可能にする方法を見つける必要があります - どうすればいいでしょうか?

トリックは、リクエストとレスポンスをペアで組み合わせることです。つまり、Moveコマンドには、入力としてMoveResponseを受け取る関連する関数があり、他の各組み合わせについても同様です。 応答のないコマンドは、今のところunitを返すと考えることができます。

Moveコマンド => (Moveコマンドのパラメータ), (関数 MoveResponse -> 何か) のペア
Turnコマンド => (Turnコマンドのパラメータ), (関数 unit -> 何か) のペア
等

これは以下のように機能します:

  • クライアントがコマンド(例:Move 100)を作成し、応答を処理する追加の関数も提供します。
  • Moveコマンドのタートル実装(インタープリター内)が入力(Distance)を処理し、MoveResponseを生成します。
  • インタープリターは、このMoveResponseを取り、クライアントが提供したペアの関連する関数を呼び出します。

このようにMoveコマンドを関数と関連付けることで、内部のタートル実装がdistanceを受け入れ、MoveResponseを返す必要があることを保証できます。

次の質問は:出力の何かは何でしょうか? クライアントが応答を処理した後の出力、つまり別のコマンド/レスポンスチェーンです!

したがって、ペアの全チェーンを再帰的な構造としてモデル化できます:

コードでは:

type TurtleProgram = 
    //         (入力パラメータ)  (応答)
    | Move     of Distance   * (MoveResponse -> TurtleProgram)
    | Turn     of Angle      * (unit -> TurtleProgram)
    | PenUp    of (* なし *)   (unit -> TurtleProgram)
    | PenDown  of (* なし *)   (unit -> TurtleProgram)
    | SetColor of PenColor   * (SetColorResponse -> TurtleProgram)

型名をTurtleCommandからTurtleProgramに変更しました。これはもはや単なるコマンドではなく、コマンドと関連する応答ハンドラの完全なチェーンになったためです。

しかし、問題があります! 各ステップには次のTurtleProgramが必要です - いつ停止するのでしょうか? 次のコマンドがないことを示す方法が必要です。

この問題を解決するために、プログラム型に特別なStopケースを追加します:

type TurtleProgram = 
    //         (入力パラメータ)  (応答)
    | Stop
    | Move     of Distance   * (MoveResponse -> TurtleProgram)
    | Turn     of Angle      * (unit -> TurtleProgram)
    | PenUp    of (* なし *)   (unit -> TurtleProgram)
    | PenDown  of (* なし *)   (unit -> TurtleProgram)
    | SetColor of PenColor   * (SetColorResponse -> TurtleProgram)

この構造にはTurtleStateへの言及がないことに注意してください。タートル状態の管理方法はインタープリターの内部的なものであり、「命令セット」の一部ではありません。

TurtleProgramは抽象構文木(AST)の一例です - 解釈(またはコンパイル)されるプログラムを表す構造です。

インタープリターのテスト

このモデルを使って小さなプログラムを作ってみましょう。ここに古い友人drawTriangleがあります:

let drawTriangle = 
    Move (100.0, fun response -> 
    Turn (120.0<Degrees>, fun () -> 
    Move (100.0, fun response -> 
    Turn (120.0<Degrees>, fun () -> 
    Move (100.0, fun response -> 
    Turn (120.0<Degrees>, fun () -> 
    Stop))))))

このプログラムは、クライアントのコマンドと応答のみを含むデータ構造です - どこにも実際のタートル関数は含まれていません! そして、はい、今のところ非常に醜いですが、すぐに修正します。

次のステップは、このデータ構造を解釈することです。

実際のタートル関数を呼び出すインタープリターを作成しましょう。たとえば、Moveケースをどのように実装すればよいでしょうか?

上記で説明したとおりです:

  • Moveケースから距離と関連する関数を取得します。
  • 距離と現在のタートル状態を使って実際のタートル関数を呼び出し、MoveResultと新しいタートル状態を取得します。
  • 関連する関数にMoveResultを渡して、プログラムの次のステップを取得します。
  • 最後に、新しいプログラムと新しいタートル状態でインタープリターを(再帰的に)再度呼び出します。
let rec interpretAsTurtle state program =
    ...
    match program  with
    | Move (dist,next) ->
        let result,newState = Turtle.move log dist state 
        let nextProgram = next result  // 次のステップを計算
        interpretAsTurtle newState nextProgram 
    ...

更新されたタートル状態が次の再帰呼び出しのパラメータとして渡されるため、可変フィールドは必要ないことがわかります。

以下はinterpretAsTurtleの完全なコードです:

let rec interpretAsTurtle state program =
    let log = printfn "%s"

    match program  with
    | Stop -> 
        state
    | Move (dist,next) ->
        let result,newState = Turtle.move log dist state 
        let nextProgram = next result  // 次のステップを計算 
        interpretAsTurtle newState nextProgram 
    | Turn (angle,next) ->
        let newState = Turtle.turn log angle state 
        let nextProgram = next()       // 次のステップを計算
        interpretAsTurtle newState nextProgram 
    | PenUp next ->
        let newState = Turtle.penUp log state 
        let nextProgram = next()
        interpretAsTurtle newState nextProgram 
    | PenDown next -> 
        let newState = Turtle.penDown log state 
        let nextProgram = next()
        interpretAsTurtle newState nextProgram 
    | SetColor (color,next) ->
        let result,newState = Turtle.setColor log color state 
        let nextProgram = next result
        interpretAsTurtle newState nextProgram

実行してみましょう:

let program = drawTriangle
let interpret = interpretAsTurtle   // インタープリターを選択 
let initialState = Turtle.initialTurtleState
interpret initialState program |> ignore

出力は以前と全く同じです:

Move 100.0
...Draw line from (0.0,0.0) to (100.0,0.0) using Black
Turn 120.0
Move 100.0
...Draw line from (100.0,0.0) to (50.0,86.6) using Black
Turn 120.0
Move 100.0
...Draw line from (50.0,86.6) to (0.0,0.0) using Black
Turn 120.0

しかし、これまでのアプローチとは異なり、全く同じプログラムを取り、新しい方法で解釈できます。 依存性注入のようなものを設定する必要はなく、単に異なるインタープリターを使用するだけです。

では、タートル状態を気にせずに移動距離を集計する別のインタープリターを作成しましょう:

let rec interpretAsDistance distanceSoFar program =
    let recurse = interpretAsDistance 
    let log = printfn "%s"

    match program with
    | Stop -> 
        distanceSoFar
    | Move (dist,next) ->
        let newDistanceSoFar = distanceSoFar + dist
        let result = Turtle.MoveOk   // 結果をハードコード
        let nextProgram = next result 
        recurse newDistanceSoFar nextProgram 
    | Turn (angle,next) ->
        // distanceSoFarは変更なし
        let nextProgram = next()
        recurse distanceSoFar nextProgram 
    | PenUp next ->
        // distanceSoFarは変更なし
        let nextProgram = next()
        recurse distanceSoFar nextProgram 
    | PenDown next -> 
        // distanceSoFarは変更なし
        let nextProgram = next()
        recurse distanceSoFar nextProgram 
    | SetColor (color,next) ->
        // distanceSoFarは変更なし
        let result = Turtle.ColorOk   // 結果をハードコード
        let nextProgram = next result
        recurse distanceSoFar nextProgram

この場合、interpretAsDistanceをローカルでrecurseとして別名を付けて、どの種類の再帰が行われているかを明確にしています。

同じプログラムをこの新しいインタープリターで実行してみましょう:

let program = drawTriangle           // 同じプログラム  
let interpret = interpretAsDistance  // インタープリターを選択 
let initialState = 0.0
interpret initialState program |> printfn "移動した総距離は %0.1f"

出力は再び予想通りです:

移動した総距離は 300.0

"タートルプログラム"ワークフローの作成

解釈するプログラムを作成するためのコードはかなり醜かったですね! コンピュテーション式を作成して見栄えを良くすることはできないでしょうか?

コンピュテーション式を作成するには、returnbind関数が必要です。これらは TurtleProgram型がジェネリックであることを要求します。

問題ありません!TurtleProgramをジェネリックにしましょう:

type TurtleProgram<'a> = 
    | Stop     of 'a
    | Move     of Distance * (MoveResponse -> TurtleProgram<'a>)
    | Turn     of Angle    * (unit -> TurtleProgram<'a>)
    | PenUp    of            (unit -> TurtleProgram<'a>)
    | PenDown  of            (unit -> TurtleProgram<'a>)
    | SetColor of PenColor * (SetColorResponse -> TurtleProgram<'a>)

Stopケースに型'aの値が関連付けられていることに注意してください。これはreturnを適切に実装するために必要です:

let returnT x = 
    Stop x

bind関数の実装はより複雑です。今のところその動作方法を気にする必要はありません - 重要なのは型が合致し、コンパイルされることです!

let rec bindT f inst  = 
    match inst with
    | Stop x -> 
        f x
    | Move(dist,next) -> 
        (*
        Move(dist,fun moveResponse -> (bindT f)(next moveResponse)) 
        *)
        // "next >> bindT f"は関数responseの短縮版
        Move(dist,next >> bindT f) 
    | Turn(angle,next) -> 
        Turn(angle,next >> bindT f)  
    | PenUp(next) -> 
        PenUp(next >> bindT f)
    | PenDown(next) -> 
        PenDown(next >> bindT f)
    | SetColor(color,next) -> 
        SetColor(color,next >> bindT f)

bindreturnが揃ったので、コンピュテーション式を作成できます:

// コンピュテーション式ビルダーを定義
type TurtleProgramBuilder() =
    member this.Return(x) = returnT x
    member this.Bind(x,f) = bindT f x
    member this.Zero(x) = returnT ()

// コンピュテーション式ビルダーのインスタンスを作成
let turtleProgram = TurtleProgramBuilder()

これで、モナディック制御フローの例(方法12)で見たように、MoveResponseを処理するワークフローを作成できます。

// ヘルパー関数
let stop = fun x -> Stop x
let move dist  = Move (dist, stop)
let turn angle  = Turn (angle, stop)
let penUp  = PenUp stop 
let penDown  = PenDown stop 
let setColor color = SetColor (color,stop)

let handleMoveResponse log moveResponse = turtleProgram {
    match moveResponse with
    | Turtle.MoveOk -> 
        ()
    | Turtle.HitBarrier ->
        // 再試行の前に90度回転
        log "おっと -- 障壁にぶつかりました -- 回転します"
        let! x = turn 90.0<Degrees>
        ()
    }

// 例
let drawTwoLines log = turtleProgram {
    let! response = move 60.0
    do! handleMoveResponse log response 
    let! response = move 60.0
    do! handleMoveResponse log response 
    }

実際のタートル関数を使ってこれを解釈してみましょう(interpretAsTurtle関数が新しいジェネリック構造を処理するように修正されていると仮定します):

let log = printfn "%s"
let program = drawTwoLines log 
let interpret = interpretAsTurtle 
let initialState = Turtle.initialTurtleState
interpret initialState program |> ignore

出力は、障壁に遭遇したときにMoveResponseが確かに正しく処理されていることを示しています:

Move 60.0
...Draw line from (0.0,0.0) to (60.0,0.0) using Black
Move 60.0
...Draw line from (60.0,0.0) to (100.0,0.0) using Black
おっと -- 障壁にぶつかりました -- 回転します
Turn 90.0

TurtleProgram型を2つの部分にリファクタリング

このアプローチは十分に機能しますが、TurtleProgram型に特別なStopケースがあることが気になります。できれば、 5つのタートルアクションに焦点を当て、それを無視できればいいのですが。

実際、これを行う方法があります。HaskellやScalazでは「フリーモナド」と呼ばれますが、F#は型クラスをサポートしていないため、 この問題を解決するための「フリーモナドパターン」と呼ぶことにします。 少しのボイラープレートを書く必要がありますが、それほど多くはありません。

トリックは、APIケースと "stop"/"keep going" ロジックを2つの別々の型に分離することです:

/// 各命令を表す型を作成
type TurtleInstruction<'next> = 
    | Move     of Distance * (MoveResponse -> 'next)
    | Turn     of Angle    * 'next
    | PenUp    of            'next
    | PenDown  of            'next
    | SetColor of PenColor * (SetColorResponse -> 'next)

/// タートルプログラムを表す型を作成
type TurtleProgram<'a> = 
    | Stop of 'a
    | KeepGoing of TurtleInstruction<TurtleProgram<'a>>

TurnPenUpPenDownの応答を単一の値に変更し、unit関数ではなくしたことにも注意してください。MoveSetColorは関数のままです。

この新しい「フリーモナド」アプローチでは、APIタイプ(この場合はTurtleInstruction)に対する単純なmap関数を書くだけです:

let mapInstr f inst  = 
    match inst with
    | Move(dist,next) ->      Move(dist,next >> f) 
    | Turn(angle,next) ->     Turn(angle,f next)  
    | PenUp(next) ->          PenUp(f next)
    | PenDown(next) ->        PenDown(f next)
    | SetColor(color,next) -> SetColor(color,next >> f)

残りのコード(returnbind、およびコンピュテーション式)は、 常に同じ方法で実装されます。これは特定のAPIに関係なく、同じです。 つまり、より多くのボイラープレートが必要ですが、考える必要は少なくなります!

インタープリターは新しいケースを処理するように変更する必要があります。以下はinterpretAsTurtleの新バージョンの一部です:

let rec interpretAsTurtle log state program =
    let recurse = interpretAsTurtle log 

    match program with
    | Stop a -> 
        state
    | KeepGoing (Move (dist,next)) ->
        let result,newState = Turtle.move log dist state 
        let nextProgram = next result // 次のプログラムを計算
        recurse newState nextProgram 
    | KeepGoing (Turn (angle,next)) ->
        let newState = Turtle.turn log angle state 
        let nextProgram = next        // 次のプログラムを直接使用
        recurse newState nextProgram

ワークフローを作成する際のヘルパー関数も調整する必要があります。以下では、元のインタープリターでの単純なコードの代わりに、 KeepGoing (Move (dist, Stop))のようなやや複雑なコードがあることがわかります。

// ヘルパー関数
let stop = Stop()
let move dist  = KeepGoing (Move (dist, Stop))    // "Stop"は関数
let turn angle  = KeepGoing (Turn (angle, stop))  // "stop"は値
let penUp  = KeepGoing (PenUp stop)
let penDown  = KeepGoing (PenDown stop)
let setColor color = KeepGoing (SetColor (color,Stop))

let handleMoveResponse log moveResponse = turtleProgram {
    ... // 以前と同じ

// 例
let drawTwoLines log = turtleProgram {
    let! response = move 60.0
    do! handleMoveResponse log response 
    let! response = move 60.0
    do! handleMoveResponse log response 
    }

これらの変更を加えれば、コードは以前と同じように動作します。

インタープリターパターンの利点と欠点

利点

  • 分離。 抽象構文木は、プログラムフローを実装から完全に分離し、多くの柔軟性を可能にします。
  • 最適化。抽象構文木は、実行前に操作や変更を加えて、最適化やその他の変換を行うことができます。たとえば、タートルプログラムでは、 ツリーを処理して、連続するすべてのTurnを単一のTurn操作に集約することができます。 これは、物理的なタートルとの通信回数を節約する単純な最適化です。TwitterのStitchライブラリ は、より洗練された方法でこのようなことを行っています。この動画に良い説明があります
  • 最小限のコードで多くの力を得られる。抽象構文木を作成する「フリーモナド」アプローチにより、APIに焦点を当て、Stop/KeepGoingロジックを無視できます。また、カスタマイズが必要な最小限のコードで済みます。 フリーモナドについて詳しく知るには、まずこの素晴らしい動画から始め、次にこの投稿こちらの投稿を参照してください。

欠点

  • 理解するのが複雑です。
  • 実行する操作が限られている場合にのみ効果的です。
  • ASTが大きくなりすぎると非効率になる可能性があります。

このバージョンのソースコードはこちら(オリジナルバージョン)こちら(「フリーモナド」バージョン)で入手できます。


使用したテクニックの再確認

この投稿では、タートルAPIを実装する13の異なる方法を見てきました。様々なテクニックを使用しました。使用されたすべてのテクニックを簡単に振り返ってみましょう:

  • 純粋でステートレスな関数。関数型プログラミングのすべての例で見られます。これらはすべてテストやモックが非常に容易です。
  • 部分適用最もシンプルな関数型プログラミングの例(方法2)で初めて見られ、メインフローがパイピングを使用できるようにタートル関数にロギング関数が適用されました。 その後、特に「関数を使った依存性注入アプローチ」(方法7)で広く使用されました。
  • オブジェクト式。クラスを作成せずにインターフェースを実装するために使用されました(方法6参照)。
  • Result型(別名Eitherモナド)。すべての関数型APIの例(たとえば方法4)で、例外を投げる代わりにエラーを返すために使用されました。
  • アプリカティブ「リフティング」(例:lift2)。通常の関数をResultの世界に持ち上げるために使用されました(方法4など)。
  • 状態管理の様々な方法
    • 可変フィールド(方法1)
    • 状態を明示的に管理し、一連の関数を通してパイプする(方法2)
    • エッジでのみ状態を持つ(方法4の関数型コア/命令型シェル)
    • エージェント内に状態を隠す(方法5)
    • ステートモナドで舞台裏で状態をスレッド化する(方法8と12のturtleワークフロー)
    • コマンドのバッチ(方法9)やイベントのバッチ(方法10)、インタープリター(方法13)を使用して状態を完全に避ける
  • 関数を型でラップする方法8で状態を管理するため(Stateモナド)と、方法13で応答を格納するために使用されました。
  • コンピュテーション式、たくさんありました!3つ作成して使用しました:
    • エラー処理のためのresult
    • タートルの状態管理のためのturtle
    • インタープリターアプローチ(方法13)でASTを構築するためのturtleProgram
  • モナディック関数のチェーン化resultturtleワークフローで行われました。基礎となる関数はモナディック(「対角」)で、通常は適切に合成できませんが、 ワークフロー内では簡単かつ透過的に順序付けできます。
  • 振る舞いをデータ構造として表現する「関数型依存性注入」の例(方法7)で、インターフェース全体ではなく単一の関数を渡せるようにするために使用されました。
  • データ中心のプロトコルを使用した分離。エージェント、バッチコマンド、イベントソーシング、インタープリターの例で見られました。
  • ロックフリーと非同期処理。エージェントを使用(方法5)。
  • コンピュテーションの「構築」と「実行」の分離turtleワークフロー(方法8と12)とturtleProgramワークフロー(方法13:インタープリター)で見られました。
  • イベントソーシングを使用して状態を再構築する。メモリ内で可変状態を維持する代わりに、イベントソーシング(方法10)FRP(方法11)の例で見られました。
  • イベントストリームFRP(方法11)の使用。ビジネスロジックを小さく、独立した、分離されたプロセッサに分割し、モノリシックなオブジェクトを避けるために使用されました。

これら13の方法を検討することは単なる楽しい演習であり、すべてのコードをすぐにストリームプロセッサやインタープリターを使用するように変換することを提案しているわけではありません!特に 関数型プログラミングに慎重な人々と一緒に作業している場合、追加の複雑さに見合う明確な利点がない限り、初期の(そしてよりシンプルな)アプローチに固執する傾向があります。


まとめ

亀は這い出て見えなくなり
無数の円のひとつだけ
縁の跡が残った
-- ウォレス・オサガメ・スティーヴンズ 著 「タートルを見る13の方法」

この投稿を楽しんでいただけたら幸いです。私も書くのを楽しみました。いつものように、意図したよりもずっと長くなってしまいましたが、読む価値があったと思っていただければ幸いです!

このような比較アプローチが好きで、もっと知りたい場合は、Yan Cuiのブログで同様のことを行っている投稿をチェックしてみてください。

F#アドベントカレンダーの残りもお楽しみください。ハッピーホリデー!

この投稿のソースコードはGitHubで入手できます。

results matching ""

    No results matching ""