この2部構成の大型投稿の第3部では、シンプルなタートルグラフィックスモデルを限界まで拡張し続けます。

第1回第2回では、 タートルグラフィックスの実装を13の異なる視点から説明しました。

しかし、投稿後に触れ忘れた方法があったことに気づきました。 そこで今回は、おまけとして2つの方法を紹介します。

前回紹介した13の方法を振り返ってみましょう。

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


14:抽象データタートル

このデザインでは、抽象データ型の概念を使ってタートルの操作をカプセル化します。

つまり、「タートル」は不透明な型として定義され、対応する一連の操作が付随します。これは標準的なF#の型であるListSetMapの定義方法と同じです。

言い換えると、この型に対して機能する関数がいくつかありますが、型の「中身」を見ることは許されません。

ある意味、これは方法1のオブジェクト指向アプローチ方法2の関数型アプローチの第3の選択肢と考えられます。

  • オブジェクト指向の実装では、内部の詳細がうまくカプセル化され、アクセスはメソッドを介してのみ行われます。オブジェクト指向のクラスの欠点は、可変であることです。
  • 関数型の実装では、TurtleStateは不変ですが、欠点は状態の内部が公開されていることです。 クライアントがこれらのフィールドにアクセスしている可能性があるため、TurtleStateの設計を変更すると、これらのクライアントが壊れる可能性があります。

抽象データ型の実装は、両方の利点を組み合わせています。タートルの状態は元の関数型の方法と同様に不変ですが、オブジェクト指向と同様にクライアントはアクセスできません。

このデザイン(および任意の抽象型)は次のようになります。

  • タートル状態型自体は公開されていますが、コンストラクタとフィールドはプライベートです。
  • 関連するTurtleモジュールの関数は、タートル状態型の内部を見ることができます(つまり、関数型設計から変更されません)。
  • タートル状態のコンストラクタはプライベートなので、Turtleモジュールにコンストラクタ関数が必要です。
  • クライアントはタートル状態型の内部を見ることができないため、Turtleモジュールの関数に完全に依存する必要があります。

これが全てです。以前の関数型バージョンにいくつかのプライバシー修飾子を追加するだけで完成です。

実装

まず、タートル状態型とTurtleモジュールの両方をAdtTurtleという共通モジュールの中に置きます。 これにより、タートル状態はAdtTurtle.Turtleモジュールの関数からアクセス可能ですが、AdtTurtleの外部からはアクセスできません。

次に、タートル状態型はTurtleStateではなくTurtleと呼ばれるようになります。これは、ほとんどオブジェクトのように扱うためです。

最後に、関連するモジュールTurtle(関数を含む)には、いくつかの特別な属性があります。

  • RequireQualifiedAccessは、関数にアクセスする際にモジュール名を使用する必要があることを意味します(Listモジュールと同様)。
  • ModuleSuffixは、モジュールが状態型と同じ名前を持つために必要です。これは汎用型(たとえばTurtle<'a>の代わりに)では必要ありません。
module AdtTurtle = 

    /// タートルを表すプライベート構造体
    type Turtle = private {
        position : Position
        angle : float<Degrees>
        color : PenColor
        penState : PenState
    }

    /// タートルを操作するための関数
    /// "RequireQualifiedAccess"はモジュール名を必ず使用する必要があることを意味します
    ///    (Listモジュールと同様)
    /// "ModuleSuffix"は、モジュールが状態型と
    ///    同じ名前を持つために必要です
    [<RequireQualifiedAccess>]
    [<CompilationRepresentation (CompilationRepresentationFlags.ModuleSuffix)>]
    module Turtle =

衝突を避けるもう一つの方法は、状態型に異なるケースを持たせるか、小文字のエイリアスを持つ異なる名前を付けることです。

type TurtleState = { ... }
type turtle = TurtleState 

module Turtle =
    let something (t:turtle) = t

名前の付け方に関わらず、新しいTurtleを構築する方法が必要です。

コンストラクタにパラメータがなく、状態が不変の場合は、関数ではなく初期値だけが必要です(たとえばSet.emptyのように)。

そうでない場合は、make(またはcreateなど)と呼ばれる関数を定義できます。

[<RequireQualifiedAccess>]
[<CompilationRepresentation (CompilationRepresentationFlags.ModuleSuffix)>]
module Turtle =

    /// 指定された色で新しいタートルを返します
    let make(initialColor) = {
        position = initialPosition
        angle = 0.0<Degrees>
        color = initialColor
        penState = initialPenState
    }

タートルモジュールの残りの関数は、方法2の実装から変更ありません。

抽象データ型のクライアント

クライアントを見てみましょう。

まず、状態が本当にプライベートかどうかを確認しましょう。以下のように状態を明示的に作成しようとすると、コンパイラエラーが発生します。

let initialTurtle = {
    position = initialPosition
    angle = 0.0<Degrees>
    color = initialColor
    penState = initialPenState
}
// コンパイラエラー FS1093: 
//    型'Turtle'の共用体ケースまたはフィールドは
//    このコードの場所からアクセスできません

コンストラクタを使用し、フィールド(positionなど)に直接アクセスしようとすると、再びコンパイラエラーが発生します。

let turtle = Turtle.make(Red)
printfn "%A" turtle.position
// コンパイラエラー FS1093: 
//    型'Turtle'の共用体ケースまたはフィールドは
//    このコードの場所からアクセスできません

しかし、Turtleモジュールの関数を使用する限り、以前と同様に安全に状態値を作成し、関数を呼び出すことができます。

// 部分適用を介してlogを組み込んだバージョン
let move = Turtle.move log
let turn = Turtle.turn log
// 以下同様

let drawTriangle() =
    Turtle.make(Red)
    |> move 100.0 
    |> turn 120.0<Degrees>
    |> move 100.0 
    |> turn 120.0<Degrees>
    |> move 100.0 
    |> turn 120.0<Degrees>

抽象データ型の利点と欠点

利点

  • すべてのコードがステートレスなので、テストが容易です。
  • 状態のカプセル化により、常に型の動作や特性に焦点が当てられます。
  • クライアントは特定の実装に依存することがないため、安全に実装を変更できます。
  • テストやパフォーマンスなどの目的で、実装を簡単に入れ替えられます(たとえば、シャドーイングや異なるアセンブリへのリンクによって)。

欠点

  • クライアントが現在のタートルの状態を管理する必要があります。
  • クライアントは実装を制御できません(たとえば、依存性注入を使用して)。

F#での抽象データ型についての詳細は、Bryan Eddsによるこのトークとスレッドを参照してください。

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


15:ケイパビリティベースのタートル

方法12の「モナディック制御フロー」アプローチでは、タートルが障壁に当たったことを知らせる応答を処理しました。

しかし、障壁に当たったにもかかわらず、move操作を何度も呼び出すことを止められませんでした。

障壁に当たった後、move操作がもう使えなくなるとどうでしょうか。使えないので乱用できません。

これを実現するには、APIを提供するのではなく、各呼び出しの後に、クライアントが次のステップで呼び出せる関数のリストを返すべきです。 通常、関数のリストにはmoveturnpenUpなどが含まれますが、障壁に当たったときはmoveがそのリストから削除されます。シンプルですが効果的です。

このテクニックは、ケイパビリティベースのセキュリティと呼ばれる認証・セキュリティ技術と密接に関連しています。 詳細に興味がある場合は、ケイパビリティベースのセキュリティに関する連載を参照してください。

ケイパビリティベースのタートルの設計

まず、各呼び出しの後に返される関数のレコードを定義します。

type MoveResponse = 
    | MoveOk 
    | HitABarrier

type SetColorResponse = 
    | ColorOk
    | OutOfInk

type TurtleFunctions = {
    move     : MoveFn option
    turn     : TurnFn
    penUp    : PenUpDownFn 
    penDown  : PenUpDownFn 
    setBlack : SetColorFn  option
    setBlue  : SetColorFn  option
    setRed   : SetColorFn  option
    }
and MoveFn =      Distance -> (MoveResponse * TurtleFunctions)
and TurnFn =      Angle    -> TurtleFunctions
and PenUpDownFn = unit     -> TurtleFunctions
and SetColorFn =  unit     -> (SetColorResponse * TurtleFunctions)

これらの宣言を詳しく見てみましょう。

まず、どこにもTurtleStateはありません。公開されたタートル関数が状態をカプセル化します。同様にlog関数もありません。

次に、関数のレコードTurtleFunctionsはAPI内の各関数(moveturnなど)のフィールドを定義します。

  • move関数はオプショナルで、使用できない可能性があります。
  • turnpenUppenDown関数は常に使用可能です。
  • setColor操作は3つの別々の関数に分割されています。各色に1つずつです。赤インクは使えなくても、青インクは使える可能性があるからです。 これらの関数が使用できない可能性があることを示すため、再びoptionを使用しています。

また、各関数の型エイリアスを宣言して、扱いやすくしています。どこでもDistance -> (MoveResponse * TurtleFunctions)と書くよりもMoveFnと書く方が簡単です。 これらの定義は相互に再帰的なので、andキーワードを使用する必要がありました。

最後に、このデザインのMoveFnの署名と方法12の以前のデザインmoveの署名の違いに注目してください。

以前のバージョン:

val move : 
    Log -> Distance -> TurtleState -> (MoveResponse * TurtleState)

新しいバージョン:

val move : 
    Distance -> (MoveResponse * TurtleFunctions)

入力側では、LogTurtleStateパラメータがなくなり、出力側ではTurtleStateTurtleFunctionsに置き換わっています。

つまり、すべてのAPI関数の出力をTurtleFunctionsレコードに変更する必要があります。

タートル操作の実装

実際に移動できるかどうか、または特定の色を使用できるかどうかを判断するために、まずこれらの要因を追跡するTurtleState型を拡張する必要があります。

type Log = string -> unit

type private TurtleState = {
    position : Position
    angle : float<Degrees>
    color : PenColor
    penState : PenState

    canMove : bool                // 新規追加!
    availableInk: Set<PenColor>   // 新規追加!
    logger : Log                  // 新規追加!
}

これは次の項目で拡張されています。

  • canMove。falseの場合、障壁に到達しており、有効なmove関数を返すべきではありません。
  • availableInkは色のセットを含みます。色がこのセットにない場合、その色に対する有効なsetColorXXX関数を返すべきではありません。
  • 最後に、log関数を状態に追加しました。これにより、各操作に明示的に渡す必要がなくなります。タートルの作成時に一度設定されます。

TurtleStateが少し醜くなっていますが、プライベートなので問題ありません!クライアントがこれを見ることはありません。

この拡張された状態を利用して、moveを変更できます。まずプライベートにし、次に新しい状態を返す前にcanMoveフラグを設定します(moveResult <> HitABarrierを使用)。

/// 関数はプライベートです!クライアントはTurtleFunctionsレコードを介してのみアクセス可能
let private 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
         canMove = (moveResult <> HitABarrier)   // 新規追加! 
        }
    (moveResult,newState)

canMoveをtrueに戻す方法が必要です!そこで、回転すると再び移動できると仮定しましょう。

その論理をturn関数に追加しましょう。

let private turn log angle state =
    log (sprintf "Turn %0.1f" angle)
    // 新しい角度を計算
    let newAngle = (state.angle + angle) % 360.0<Degrees>
    // 新規追加!! 回転後は常に移動可能と仮定
    let canMove = true
    // 状態を更新
    {state with angle = newAngle; canMove = canMove}

penUppenDown関数は、プライベートにする以外は変更ありません。

最後の操作setColorでは、一度使用されるとすぐにインクを利用可能セットから削除します!

let private setColor log color state =
    let colorResult = 
        if color = Red then OutOfInk else ColorOk
    log (sprintf "SetColor %A" color)

    // 新規追加! 色のインクを利用可能なインクから削除
    let newAvailableInk = state.availableInk |> Set.remove color

    // 新しい状態とSetColor結果を返す
    let newState = {state with color = color; availableInk = newAvailableInk}
    (colorResult,newState)

最後に、TurtleStateからTurtleFunctionsレコードを作成する関数が必要です。これをcreateTurtleFunctionsと呼びましょう。

以下に完全なコードを示し、詳細を説明します。

/// TurtleStateに関連するTurtleFunctions構造を作成
let rec private createTurtleFunctions state =
    let ctf = createTurtleFunctions  // エイリアス

    // move関数を作成
    // タートルが移動できない場合はNoneを返す
    let move = 
        // 内部関数
        let f dist = 
            let resp, newState = move state.logger dist state
            (resp, ctf newState)

        // タートルが移動可能な場合は内部関数のSomeを返し、
        // そうでない場合はNoneを返す
        if state.canMove then
            Some f
        else
            None

    // turn関数を作成
    let turn angle = 
        let newState = turn state.logger angle state
        ctf newState

    // ペンの状態関数を作成
    let penDown() = 
        let newState = penDown state.logger state
        ctf newState

    let penUp() = 
        let newState = penUp state.logger state
        ctf newState

    // 色設定関数を作成
    let setColor color = 
        // 内部関数
        let f() = 
            let resp, newState = setColor state.logger color state
            (resp, ctf newState)

        // その色が利用可能な場合は内部関数のSomeを返し、
        // そうでない場合はNoneを返す
        if state.availableInk |> Set.contains color then
            Some f
        else
            None

    let setBlack = setColor Black
    let setBlue = setColor Blue
    let setRed = setColor Red

    // 構造を返す
    {
    move     = move
    turn     = turn
    penUp    = penUp 
    penDown  = penDown 
    setBlack = setBlack
    setBlue  = setBlue  
    setRed   = setRed   
    }

この動作を見てみましょう。

まず、この関数は自身を参照するため、recキーワードが必要です。また、より短いエイリアス(ctf)も追加しています。

次に、APIの各関数の新しいバージョンが作成されます。たとえば、新しいturn関数は次のように定義されます。

let turn angle = 
    let newState = turn state.logger angle state
    ctf newState

これは元のturn関数をロガーと状態で呼び出し、再帰呼び出し(ctf)を使用して新しい状態を関数のレコードに変換します。

moveのようなオプショナルな関数の場合、少し複雑になります。 内部関数fが元のmoveを使用して定義され、state.canMoveフラグの設定に応じて、fSomeとして返されるか、Noneが返されます。

// move関数を作成
// タートルが移動できない場合はNoneを返す
let move = 
    // 内部関数
    let f dist = 
        let resp, newState = move state.logger dist state
        (resp, ctf newState)

    // タートルが移動可能な場合は内部関数のSomeを返し、
    // そうでない場合はNoneを返す
    if state.canMove then
        Some f
    else
        None

同様に、setColorの場合、内部関数fが定義され、色パラメータがstate.availableInkコレクションに含まれているかどうかに応じて返されるかどうかが決まります。

let setColor color = 
    // 内部関数
    let f() = 
        let resp, newState = setColor state.logger color state
        (resp, ctf newState)

    // その色が利用可能な場合は内部関数のSomeを返し、
    // そうでない場合はNoneを返す
    if state.availableInk |> Set.contains color then
        Some f
    else
        None

最後に、これらの関数がすべてレコードに追加されます。

// 構造を返す
{
move     = move
turn     = turn
penUp    = penUp 
penDown  = penDown 
setBlack = setBlack
setBlue  = setBlue  
setRed   = setRed   
}

これがTurtleFunctionsレコードの構築方法です!

あと一つ必要なのは、TurtleFunctionsの初期値を作成するコンストラクタです。APIに直接アクセスできなくなったので、これがクライアントが利用できる唯一のパブリック関数となります!

/// 初期のタートルを返します。
/// これが唯一のパブリック関数です!
let make(initialColor, log) = 
    let state = {
        position = initialPosition
        angle = 0.0<Degrees>
        color = initialColor
        penState = initialPenState
        canMove = true
        availableInk = [Black; Blue; Red] |> Set.ofList
        logger = log
    }                
    createTurtleFunctions state

この関数はlog関数を組み込み、新しい状態を作成し、createTurtleFunctionsを呼び出してクライアントが使用するTurtleFunctionレコードを返します。

ケイパビリティベースのタートルのクライアントの実装

では、これを使ってみましょう。まず、move 60を行い、その後再びmove 60を試みます。 2回目の移動で境界(100の位置)に達するはずなので、その時点でmove関数は利用できなくなるはずです。

まず、Turtle.makeTurtleFunctionsレコードを作成します。そして、すぐに移動することはできず、まずmove関数が利用可能かどうかを確認する必要があります。

let testBoundary() =
    let turtleFns = Turtle.make(Red,log)
    match turtleFns.move with
    | None -> 
        log "エラー:移動1を実行できません"
    | Some moveFn -> 
        ...

最後のケースでは、moveFnが利用可能なので、60の距離で呼び出すことができます。

関数の出力は、MoveResponse型と新しいTurtleFunctionsレコードのペアです。

MoveResponseは無視し、TurtleFunctionsレコードを再度確認して、次の移動ができるかどうかを確認します。

let testBoundary() =
    let turtleFns = Turtle.make(Red,log)
    match turtleFns.move with
    | None -> 
        log "エラー:移動1を実行できません"
    | Some moveFn -> 
        let (moveResp,turtleFns) = moveFn 60.0 
        match turtleFns.move with
        | None -> 
            log "エラー:移動2を実行できません"
        | Some moveFn -> 
            ...

そして最後にもう一度:

let testBoundary() =
    let turtleFns = Turtle.make(Red,log)
    match turtleFns.move with
    | None -> 
        log "エラー:移動1を実行できません"
    | Some moveFn -> 
        let (moveResp,turtleFns) = moveFn 60.0 
        match turtleFns.move with
        | None -> 
            log "エラー:移動2を実行できません"
        | Some moveFn -> 
            let (moveResp,turtleFns) = moveFn 60.0 
            match turtleFns.move with
            | None -> 
                log "エラー:移動3を実行できません"
            | Some moveFn -> 
                log "成功"

これを実行すると、以下の出力が得られます:

Move 60.0
...Draw line from (0.0,0.0) to (60.0,0.0) using Red
Move 60.0
...Draw line from (60.0,0.0) to (100.0,0.0) using Red
エラー:移動3を実行できません

これにより、この概念が実際に機能していることがわかります!

このネストされたオプションマッチングは非常に醜いので、簡単なmaybeワークフローを作成して見た目を良くしましょう:

type MaybeBuilder() =         
    member this.Return(x) = Some x
    member this.Bind(x,f) = Option.bind f x
    member this.Zero() = Some()
let maybe = MaybeBuilder()

そして、「maybe」ワークフロー内で使用できるログ関数を作成します:

/// メッセージをログに記録し、Some()を返す関数
/// 「maybe」ワークフロー内で使用
let logO message =
    printfn "%s" message
    Some ()

これで、maybeワークフローを使用して色を設定してみましょう:

let testInk() =
    maybe {
    // タートルを作成
    let turtleFns = Turtle.make(Black,log)

    // "setRed"関数の取得を試みる
    let! setRedFn = turtleFns.setRed 

    // 取得できた場合、使用する
    let (resp,turtleFns) = setRedFn() 

    // "move"関数の取得を試みる
    let! moveFn = turtleFns.move 

    // 取得できた場合、赤インクで60の距離を移動
    let (resp,turtleFns) = moveFn 60.0 

    // "setRed"関数がまだ利用可能かどうかを確認
    do! match turtleFns.setRed with
        | None -> 
            logO "エラー:赤インクをもう使用できません"
        | Some _ -> 
            logO "成功:赤インクをまだ使用できます"

    // "setBlue"関数がまだ利用可能かどうかを確認
    do! match turtleFns.setBlue with
        | None -> 
            logO "エラー:青インクをもう使用できません"
        | Some _ -> 
            logO "成功:青インクをまだ使用できます"

    } |> ignore

この出力は次のようになります:

SetColor Red
Move 60.0
...Draw line from (0.0,0.0) to (60.0,0.0) using Red
エラー:赤インクをもう使用できません
成功:青インクをまだ使用できます

実際、maybeワークフローを使用するのはあまり良いアイデアではありません。最初の失敗でワークフローが終了してしまうからです! 実際のコードでは、もう少し良いものを考える必要がありますが、この概念は理解していただけたと思います。

ケイパビリティベースのアプローチの利点と欠点

利点

  • クライアントがAPIを乱用することを防ぎます。
  • クライアントに影響を与えずにAPIを進化(および退化)させることができます。たとえば、関数のレコードで各色関数にNoneをハードコードすることで、モノクロのみのタートルに移行できます。 その後、setColorの実装を安全に削除できます。このプロセス中、クライアントは一切壊れません!これはRESTfulウェブサービスのHATEOASアプローチに似ています。
  • 関数のレコードがインターフェースとして機能するため、クライアントは特定の実装から分離されています。

欠点

  • 実装が複雑です。
  • クライアントのロジックが非常に複雑になります。関数が利用可能かどうかを常に確認する必要があるためです!
  • データ指向のAPIとは異なり、APIは簡単にシリアライズできません。

ケイパビリティベースのセキュリティについての詳細は、私の投稿を参照するか、「エンタープライズ三目並べ」のビデオをご覧ください。

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

まとめ

私には三つの心があった
フィンガーツリーのように
その中にいるのは三匹の不変タートル
-- ウォレス・オサガメ・スティーヴンズ 著 「タートルを見る13の方法」

追加の方法を2つ紹介できて、すっきりしました!お読みいただきありがとうございます!

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

results matching ""

    No results matching ""