タートルを見る13の方法 - 追補
この2部構成の大型投稿の第3部では、シンプルなタートルグラフィックスモデルを限界まで拡張し続けます。
第1回と第2回では、 タートルグラフィックスの実装を13の異なる視点から説明しました。
しかし、投稿後に触れ忘れた方法があったことに気づきました。 そこで今回は、おまけとして2つの方法を紹介します。
- 方法14:抽象データタートル。抽象データ型を使ってタートルの実装詳細をカプセル化します。
- 方法15:ケイパビリティベースのタートル。タートルの現在の状態に基づいて、 クライアントが利用できるタートル関数を制御します。
前回紹介した13の方法を振り返ってみましょう。
- 方法1. 基本的なオブジェクト指向アプローチ:可変状態を持つクラスを作ります。
- 方法2. 基本的な関数型アプローチ:不変の状態を持つ関数のモジュールを作ります。
- 方法3. オブジェクト指向のコアを持つAPI:状態を持つコアクラスを呼び出すオブジェクト指向APIを作ります。
- 方法4. 関数型のコアを持つAPI:状態を持たないコア関数を使う、状態を持つAPIを作ります。
- 方法5. エージェントの前面にあるAPI:メッセージキューを使っててエージェントと通信するAPIを作ります。
- 方法6. インターフェースを使った依存性注入:インターフェースまたは関数のレコードを使って、実装をAPIから分離します。
- 方法7. 関数を使った依存性注入:関数パラメータを渡すことで、実装をAPIから分離します。
- 方法8. Stateモナドを使ったバッチ処理:状態を追跡する特別な「タートルワークフロー」コンピュテーション式を作ります。
- 方法9. コマンドオブジェクトを使ったバッチ処理:タートルのコマンドを表す型を作り、コマンドのリストを一括処理します。
- 幕間:データ型を使った意識的な分離。データまたはインターフェースを使った分離に関するメモ。
- 方法10. イベントソーシング:過去のイベントのリストから状態を構築します。
- 方法11. 関数型リアクティブプログラミング(ストリーム処理):ビジネスロジックが以前のイベントに反応することに基づいています。
- エピソードV:タートルの逆襲:一部のコマンドが失敗する可能性を考慮するように、タートルAPIを変更します。
- 方法12. モナディック制御フロー:タートルワークフロー内で、以前のコマンドの結果に基づいて決定を行います。
- 方法13. タートルインタープリター:タートルプログラミングとタートルの実装を完全に分離し、ほぼフリーモナドを実現します。
- 使用したテクニックの再確認。
この投稿のソースコードはGitHubで入手できます。
14:抽象データタートル
Section titled “14:抽象データタートル”このデザインでは、抽象データ型の概念を使ってタートルの操作をカプセル化します。
つまり、「タートル」は不透明な型として定義され、対応する一連の操作が付随します。これは標準的なF#の型であるList、Set、Mapの定義方法と同じです。
言い換えると、この型に対して機能する関数がいくつかありますが、型の「中身」を見ることは許されません。
ある意味、これは方法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の実装から変更ありません。
抽象データ型のクライアント
Section titled “抽象データ型のクライアント”クライアントを見てみましょう。
まず、状態が本当にプライベートかどうかを確認しましょう。以下のように状態を明示的に作成しようとすると、コンパイラエラーが発生します。
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 loglet 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>抽象データ型の利点と欠点
Section titled “抽象データ型の利点と欠点”利点
- すべてのコードがステートレスなので、テストが容易です。
- 状態のカプセル化により、常に型の動作や特性に焦点が当てられます。
- クライアントは特定の実装に依存することがないため、安全に実装を変更できます。
- テストやパフォーマンスなどの目的で、実装を簡単に入れ替えられます(たとえば、シャドーイングや異なるアセンブリへのリンクによって)。
欠点
- クライアントが現在のタートルの状態を管理する必要があります。
- クライアントは実装を制御できません(たとえば、依存性注入を使用して)。
F#での抽象データ型についての詳細は、Bryan Eddsによるこのトークとスレッドを参照してください。
このバージョンのソースコードはこちらで入手できます。
15:ケイパビリティベースのタートル
Section titled “15:ケイパビリティベースのタートル”方法12の「モナディック制御フロー」アプローチでは、タートルが障壁に当たったことを知らせる応答を処理しました。
しかし、障壁に当たったにもかかわらず、move操作を何度も呼び出すことを止められませんでした。
障壁に当たった後、move操作がもう使えなくなるとどうでしょうか。使えないので乱用できません。
これを実現するには、APIを提供するのではなく、各呼び出しの後に、クライアントが次のステップで呼び出せる関数のリストを返すべきです。
通常、関数のリストにはmove、turn、penUpなどが含まれますが、障壁に当たったときはmoveがそのリストから削除されます。シンプルですが効果的です。
このテクニックは、ケイパビリティベースのセキュリティと呼ばれる認証・セキュリティ技術と密接に関連しています。 詳細に興味がある場合は、ケイパビリティベースのセキュリティに関する連載を参照してください。
ケイパビリティベースのタートルの設計
Section titled “ケイパビリティベースのタートルの設計”まず、各呼び出しの後に返される関数のレコードを定義します。
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 -> TurtleFunctionsand PenUpDownFn = unit -> TurtleFunctionsand SetColorFn = unit -> (SetColorResponse * TurtleFunctions)これらの宣言を詳しく見てみましょう。
まず、どこにもTurtleStateはありません。公開されたタートル関数が状態をカプセル化します。同様にlog関数もありません。
次に、関数のレコードTurtleFunctionsはAPI内の各関数(move、turnなど)のフィールドを定義します。
move関数はオプショナルで、使用できない可能性があります。turn、penUp、penDown関数は常に使用可能です。setColor操作は3つの別々の関数に分割されています。各色に1つずつです。赤インクは使えなくても、青インクは使える可能性があるからです。 これらの関数が使用できない可能性があることを示すため、再びoptionを使用しています。
また、各関数の型エイリアスを宣言して、扱いやすくしています。どこでもDistance -> (MoveResponse * TurtleFunctions)と書くよりもMoveFnと書く方が簡単です。
これらの定義は相互に再帰的なので、andキーワードを使用する必要がありました。
最後に、このデザインのMoveFnの署名と方法12の以前のデザインのmoveの署名の違いに注目してください。
以前のバージョン:
val move : Log -> Distance -> TurtleState -> (MoveResponse * TurtleState)新しいバージョン:
val move : Distance -> (MoveResponse * TurtleFunctions)入力側では、LogとTurtleStateパラメータがなくなり、出力側ではTurtleStateがTurtleFunctionsに置き換わっています。
つまり、すべてのAPI関数の出力をTurtleFunctionsレコードに変更する必要があります。
タートル操作の実装
Section titled “タートル操作の実装”実際に移動できるかどうか、または特定の色を使用できるかどうかを判断するために、まずこれらの要因を追跡する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}penUpとpenDown関数は、プライベートにする以外は変更ありません。
最後の操作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フラグの設定に応じて、fがSomeとして返されるか、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 = moveturn = turnpenUp = penUppenDown = penDownsetBlack = setBlacksetBlue = setBluesetRed = 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レコードを返します。
ケイパビリティベースのタートルのクライアントの実装
Section titled “ケイパビリティベースのタートルのクライアントの実装”では、これを使ってみましょう。まず、move 60を行い、その後再びmove 60を試みます。
2回目の移動で境界(100の位置)に達するはずなので、その時点でmove関数は利用できなくなるはずです。
まず、Turtle.makeでTurtleFunctionsレコードを作成します。そして、すぐに移動することはできず、まず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 RedMove 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 RedMove 60.0...Draw line from (0.0,0.0) to (60.0,0.0) using Redエラー:赤インクをもう使用できません成功:青インクをまだ使用できます実際、maybeワークフローを使用するのはあまり良いアイデアではありません。最初の失敗でワークフローが終了してしまうからです!
実際のコードでは、もう少し良いものを考える必要がありますが、この概念は理解していただけたと思います。
ケイパビリティベースのアプローチの利点と欠点
Section titled “ケイパビリティベースのアプローチの利点と欠点”利点
- クライアントがAPIを乱用することを防ぎます。
- クライアントに影響を与えずにAPIを進化(および退化)させることができます。たとえば、関数のレコードで各色関数に
Noneをハードコードすることで、モノクロのみのタートルに移行できます。 その後、setColorの実装を安全に削除できます。このプロセス中、クライアントは一切壊れません!これはRESTfulウェブサービスのHATEOASアプローチに似ています。 - 関数のレコードがインターフェースとして機能するため、クライアントは特定の実装から分離されています。
欠点
- 実装が複雑です。
- クライアントのロジックが非常に複雑になります。関数が利用可能かどうかを常に確認する必要があるためです!
- データ指向のAPIとは異なり、APIは簡単にシリアライズできません。
ケイパビリティベースのセキュリティについての詳細は、私の投稿を参照するか、「エンタープライズ三目並べ」のビデオをご覧ください。
このバージョンのソースコードはこちらで入手できます。
私には三つの心があった
フィンガーツリーのように
その中にいるのは三匹の不変タートル
— ウォレス・オサガメ・スティーヴンズ 著 「タートルを見る13の方法」
追加の方法を2つ紹介できて、すっきりしました!お読みいただきありがとうございます!
この投稿のソースコードはGitHubで入手可能です。