木構造の実践的な利用
この記事はシリーズの第6弾です。
前回の記事では、ジェネリック型について簡単に見てきました。
この記事では、現実的な場面で木構造と畳み込みを使う例をいくつか掘り下げていきます。
シリーズの内容
Section titled “シリーズの内容”シリーズの内容は次の通りです。
- パート1: 再帰型とカタモーフィズム入門
- パート2: カタモーフィズムの例
- パート3: 畳み込みの紹介
- パート4: 畳み込みを理解する
- パート5: ジェネリック再帰型
- パート6: 木構造の実践的な利用
ジェネリックな Tree 型の定義
Section titled “ジェネリックな Tree 型の定義”今回は、以前検討したファイルシステムドメインに着想を得たジェネリックな Tree 型を使って作業を進めます。
元の設計は次のようでした。
type FileSystemItem = | File of FileInfo | Directory of DirectoryInfoand FileInfo = {name:string; fileSize:int}and DirectoryInfo = {name:string; dirSize:int; subitems:FileSystemItem list}データと再帰を分離し、次のようなジェネリックな Tree 型を作れます。
type Tree<'LeafData,'INodeData> = | LeafNode of 'LeafData | InternalNode of 'INodeData * Tree<'LeafData,'INodeData> seqサブアイテムを表すのに list ではなく seq を使っていることに注目してください。その理由はすぐに明らかになります。
ファイルシステムドメインは、Tree を使って次のようにモデル化できます。リーフノードには FileInfo を、内部ノードには DirectoryInfo を関連付けます。
type FileInfo = {name:string; fileSize:int}type DirectoryInfo = {name:string; dirSize:int}
type FileSystemItem = Tree<FileInfo,DirectoryInfo>Tree 向けの cata と fold
Section titled “Tree 向けの cata と fold”いつものように cata と fold を定義できます。
module Tree =
let rec cata fLeaf fNode (tree:Tree<'LeafData,'INodeData>) :'r = let recurse = cata fLeaf fNode match tree with | LeafNode leafInfo -> fLeaf leafInfo | InternalNode (nodeInfo,subtrees) -> fNode nodeInfo (subtrees |> Seq.map recurse)
let rec fold fLeaf fNode acc (tree:Tree<'LeafData,'INodeData>) :'r = let recurse = fold fLeaf fNode match tree with | LeafNode leafInfo -> fLeaf acc leafInfo | InternalNode (nodeInfo,subtrees) -> // このレベルでのローカルな累積値を決定 let localAccum = fNode acc nodeInfo // Seq.foldを使ってすべてのサブアイテムにローカルな累積値を通す let finalAccum = subtrees |> Seq.fold recurse localAccum // ...そして返す finalAccum今回は Tree 型に対して foldBack を実装しません。スタックオーバーフローを引き起こすほど木が深くなることは考えにくいからです。
内部データが必要な関数は cata を使えます。
Tree を使ったファイルシステムドメインのモデリング
Section titled “Tree を使ったファイルシステムドメインのモデリング”前回の例と同じ値を使ってテストしてみましょう。
let fromFile (fileInfo:FileInfo) = LeafNode fileInfo
let fromDir (dirInfo:DirectoryInfo) subitems = InternalNode (dirInfo,subitems)
let readme = fromFile {name="readme.txt"; fileSize=1}let config = fromFile {name="config.xml"; fileSize=2}let build = fromFile {name="build.bat"; fileSize=3}let src = fromDir {name="src"; dirSize=10} [readme; config; build]let bin = fromDir {name="bin"; dirSize=10} []let root = fromDir {name="root"; dirSize=5} [src; bin]totalSize 関数は、前回の記事とほぼ同じです。
let totalSize fileSystemItem = let fFile acc (file:FileInfo) = acc + file.fileSize let fDir acc (dir:DirectoryInfo)= acc + dir.dirSize Tree.fold fFile fDir 0 fileSystemItem
readme |> totalSize // 1src |> totalSize // 16 = 10 + (1 + 2 + 3)root |> totalSize // 31 = 5 + 16 + 10largestFile 関数も同様です。
let largestFile fileSystemItem = let fFile (largestSoFarOpt:FileInfo option) (file:FileInfo) = match largestSoFarOpt with | None -> Some file | Some largestSoFar -> if largestSoFar.fileSize > file.fileSize then Some largestSoFar else Some file
let fDir largestSoFarOpt dirInfo = largestSoFarOpt
// foldを呼び出す Tree.fold fFile fDir None fileSystemItem
readme |> largestFile// Some {name = "readme.txt"; fileSize = 1}
src |> largestFile// Some {name = "build.bat"; fileSize = 3}
bin |> largestFile// None
root |> largestFile// Some {name = "build.bat"; fileSize = 3}このセクションのソースコードは このgist で入手できます。
Tree 型の実践的な利用
Section titled “Tree 型の実践的な利用”Tree 型は、実際のファイルシステムもモデル化できます!
リーフノードの型を System.IO.FileInfo に、内部ノードの型を System.IO.DirectoryInfo に設定するだけです。
open Systemopen System.IO
type FileSystemTree = Tree<IO.FileInfo,IO.DirectoryInfo>さまざまなノードを作成するヘルパーメソッドも用意しましょう。
let fromFile (fileInfo:FileInfo) = LeafNode fileInfo
let rec fromDir (dirInfo:DirectoryInfo) = let subItems = seq{ yield! dirInfo.EnumerateFiles() |> Seq.map fromFile yield! dirInfo.EnumerateDirectories() |> Seq.map fromDir } InternalNode (dirInfo,subItems)サブアイテムに list ではなく seq を理由がこれでわかります。
seq は遅延評価なので、実際にディスクにアクセスしなくてもノードを作成できるのです。
次は、実際のファイル情報を使った totalSize 関数です。
let totalSize fileSystemItem = let fFile acc (file:FileInfo) = acc + file.Length let fDir acc (dir:DirectoryInfo)= acc Tree.fold fFile fDir 0L fileSystemItem現在のディレクトリのサイズを確認してみましょう。
// カレントディレクトリを現在のソースディレクトリに設定Directory.SetCurrentDirectory __SOURCE_DIRECTORY__
// カレントディレクトリをTreeとして取得let currentDir = fromDir (DirectoryInfo("."))
// カレントディレクトリのサイズを取得currentDir |> totalSize同様に、一番大きなファイルを取得できます。
let largestFile fileSystemItem = let fFile (largestSoFarOpt:FileInfo option) (file:FileInfo) = match largestSoFarOpt with | None -> Some file | Some largestSoFar -> if largestSoFar.Length > file.Length then Some largestSoFar else Some file
let fDir largestSoFarOpt dirInfo = largestSoFarOpt
// foldを呼び出す Tree.fold fFile fDir None fileSystemItem
currentDir |> largestFileこれが、ジェネリックな再帰型を使う大きな利点の一つです。現実世界の階層構造を木構造に変換できれば、畳み込みのメリットをすべて「無料で」得られるのです。
Tree 型の写像
Section titled “Tree 型の写像”ジェネリック型を使うもう一つの利点は、map 関数のような操作ができることです。mHap は、構造を変えずにすべての要素を新しい型に変換します。
実際のファイルシステムでこれを見てみましょう。まずは、Tree 型の map 関数を定義しましょう。
map 関数の実装は、以下のルールに従って機械的に行うことができます。
- 構造内の各ケースを処理する関数パラメータを作成する
- 再帰しないケースの場合
- まず、関数パラメータを使ってそのケースに関連する非再帰データを変換する
- 次に、結果を同じケースコンストラクタでラップする
- 再帰的なケースの場合、以下のステップを実行する
- まず、関数パラメータを使ってそのケースに関連する非再帰データを変換する
- 次に、ネストされた値を再帰的に
mapする - 最後に、結果を同じケースのコンストラクタでラップする
これらのルールに従って作成した Tree 型の map 関数の実装は次のとおりです。
module Tree =
let rec cata ...
let rec fold ...
let rec map fLeaf fNode (tree:Tree<'LeafData,'INodeData>) = let recurse = map fLeaf fNode match tree with | LeafNode leafInfo -> let newLeafInfo = fLeaf leafInfo LeafNode newLeafInfo | InternalNode (nodeInfo,subtrees) -> let newNodeInfo = fNode nodeInfo let newSubtrees = subtrees |> Seq.map recurse InternalNode (newNodeInfo, newSubtrees)Tree.map のシグネチャを見ると、すべてのリーフのデータが型 'a に、すべてのノードのデータが型 'b に変換され、
最終的な結果は Tree<'a,'b> になることがわかります。
val map : fLeaf:('LeafData -> 'a) -> fNode:('INodeData -> 'b) -> tree:Tree<'LeafData,'INodeData> -> Tree<'a,'b>Tree.iter 関数も同様の方法で定義できます。
module Tree =
let rec map ...
let rec iter fLeaf fNode (tree:Tree<'LeafData,'INodeData>) = let recurse = iter fLeaf fNode match tree with | LeafNode leafInfo -> fLeaf leafInfo | InternalNode (nodeInfo,subtrees) -> subtrees |> Seq.iter recurse fNode nodeInfo例:ディレクトリ一覧の作成
Section titled “例:ディレクトリ一覧の作成”map 関数を使ってファイルシステムをディレクトリ一覧に変換してみましょう。ディレクトリ一覧とは、各ファイルやディレクトリの情報を含む文字列の木構造のことです。
コードは以下のようになります。
let dirListing fileSystemItem = let printDate (d:DateTime) = d.ToString() let mapFile (fi:FileInfo) = sprintf "%10i %s %-s" fi.Length (printDate fi.LastWriteTime) fi.Name let mapDir (di:DirectoryInfo) = di.FullName Tree.map mapFile mapDir fileSystemItem変換された文字列は次のように出力することができます。
currentDir|> dirListing|> Tree.iter (printfn "%s") (printfn "\n%s")結果はこのようになります。
8315 10/08/2015 23:37:41 Fold.fsx 3680 11/08/2015 23:59:01 FoldAndRecursiveTypes.fsproj 1010 11/08/2015 01:19:07 FoldAndRecursiveTypes.sln 1107 11/08/2015 23:59:01 HtmlDom.fsx 79 11/08/2015 01:21:54 LinkedList.fsxこの例のソースコードは、このgist で入手できます。
例:並列 grep
Section titled “例:並列 grep”もっと複雑な例として、「grep」コマンドのような並列検索機能を fold 関数を使って作成してみます。
ロジックは以下の通りです。
fold関数を使ってファイルを反復処理します。- 各ファイルに対して、名前が指定のパターンに一致しなければ、
Noneを返します。 - 処理対象のファイルであれば、ファイル内のマッチした行をすべて返す非同期処理を返します。
- これらの非同期処理 (fold の出力) をすべて集約してシーケンスにします。
- 非同期処理のシーケンスを
Async.Parallel関数を使って単一の非同期処理に変換し、結果の一覧を取得します。
メインのコードを書く前に、ヘルパー関数が必要です。
まず、ファイル内の行を非同期で畳み込むジェネリック関数を作成します。 これがパターンマッチングの基盤となります。
/// ファイル内の行を非同期で畳み込む/// 現在の行と行番号をフォルダ関数に渡す。////// シグネチャ:/// folder:('a -> int -> string -> 'a) ->/// acc:'a ->/// fi:FileInfo ->/// Async<'a>let foldLinesAsync folder acc (fi:FileInfo) = async { let mutable acc = acc let mutable lineNo = 1 use sr = new StreamReader(path=fi.FullName) while not sr.EndOfStream do let! lineText = sr.ReadLineAsync() |> Async.AwaitTask acc <- folder acc lineNo lineText lineNo <- lineNo + 1 return acc }次に、Async 値に対して map を行うヘルパー関数を作成します。
let asyncMap f asyncX = async { let! x = asyncX return (f x) }いよいよ本題のロジックです。 textPattern と FileInfo が与えられたとき、 textPattern に一致する行のリストを非同期で返す関数を作ります。
/// ファイル内の一致する行を、async<string list>として返すlet matchPattern textPattern (fi:FileInfo) = // 正規表現を設定 let regex = Text.RegularExpressions.Regex(pattern=textPattern)
// "fold"で使う関数を設定 let folder results lineNo lineText = if regex.IsMatch lineText then let result = sprintf "%40s:%-5i %s" fi.Name lineNo lineText result :: results else // そのまま通過 results
// メインのフロー fi |> foldLinesAsync folder [] // foldの出力は逆順なので、反転させる |> asyncMap List.revそして、いよいよ grep 関数の実装です。
let grep filePattern textPattern fileSystemItem = let regex = Text.RegularExpressions.Regex(pattern=filePattern)
/// ファイルがパターンに一致する場合 /// マッチングを行い、Some asyncを返す、そうでなければNone let matchFile (fi:FileInfo) = if regex.IsMatch fi.Name then Some (matchPattern textPattern fi) else None
/// ファイルを処理し、その非同期処理をリストに追加 let fFile asyncs (fi:FileInfo) = // 非同期処理のリストに追加 (matchFile fi) :: asyncs
// ディレクトリの場合、非同期処理のリストをそのまま通過 let fDir asyncs (di:DirectoryInfo) = asyncs
fileSystemItem |> Tree.fold fFile fDir [] // 非同期処理のリストを取得 |> Seq.choose id // Someを選択(ファイルが処理された場所) |> Async.Parallel // すべての非同期処理を一つの非同期処理にマージ |> asyncMap (Array.toList >> List.collect id) // 配列のリストを一つのリストにフラット化実際に動かしてみましょう!
currentDir|> grep "fsx" "LinkedList"|> Async.RunSynchronously結果はこのようになります。
" SizeOfTypes.fsx:120 type LinkedList<'a> = ";" SizeOfTypes.fsx:122 | Cell of head:'a * tail:LinkedList<'a>";" SizeOfTypes.fsx:125 let S = size(LinkedList<'a>)";" RecursiveTypesAndFold-3.fsx:15 // LinkedList";" RecursiveTypesAndFold-3.fsx:18 type LinkedList<'a> = ";" RecursiveTypesAndFold-3.fsx:20 | Cons of head:'a * tail:LinkedList<'a>";" RecursiveTypesAndFold-3.fsx:26 module LinkedList = ";" RecursiveTypesAndFold-3.fsx:39 list:LinkedList<'a> ";" RecursiveTypesAndFold-3.fsx:64 list:LinkedList<'a> -> ";およそ40行のコードでこのような機能を実現できました。簡潔に書けるのは、さまざまな種類の fold と map 関数を使うことで再帰処理を隠し、
パターンマッチングロジックだけに集中できるからです。
もちろん、この実装は効率的ではなく最適化されていません(各行に対して非同期処理を生成するため)。実用的な grep としては使えませんが、fold 関数の持つ力を示す良い例です。
この例のソースコードは、このgist で入手できます。
例:ファイルシステムのデータベースへの保存
Section titled “例:ファイルシステムのデータベースへの保存”次の例では、ファイルシステムの木構造をデータベースに保存する方法を見ていきます。正直なところ、そんなことをする理由は特にありませんが、 ここで示す仕組みは、どんな階層構造を保存ときにも使えるので、ひとまず実演してみましょう。
データベースでファイルシステムの階層構造を表現するために、4 つのテーブルを用意します。
DbDirは、各ディレクトリの情報を保存します。DbFileは、各ファイルの情報を保存します。DbDir_Fileは、ディレクトリとファイルの関係を保存します。DbDir_Dirは、親ディレクトリと子ディレクトリの関係を保存します。
データベーステーブルの定義は次のとおりです。
CREATE TABLE DbDir ( DirId int IDENTITY NOT NULL, Name nvarchar(50) NOT NULL)
CREATE TABLE DbFile ( FileId int IDENTITY NOT NULL, Name nvarchar(50) NOT NULL, FileSize int NOT NULL)
CREATE TABLE DbDir_File ( DirId int NOT NULL, FileId int NOT NULL)
CREATE TABLE DbDir_Dir ( ParentDirId int NOT NULL, ChildDirId int NOT NULL)とてもシンプルですね。しかし、ディレクトリとその子アイテムとの関係すべてを保存するには、まずすべての子アイテムの ID が必要であり、 さらに各子ディレクトリもそれぞれの子の ID を必要とし、以下同様に階層が続いていきます。
そのため、階層下位のデータにアクセスできるように cata を使用する必要があります( fold は使えません)。
データベース関数の実装
Section titled “データベース関数の実装”今回は SQL Provider を使いません。代わりに、次のようなダミー関数をはじめとして、 独自のテーブル挿入関数を作成しました。
/// DbFileレコードを挿入let insertDbFile name (fileSize:int64) = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s size:%i" "DbFile" id name fileSize実際のデータベースでは、IDENTITYカラムは自動生成されますが、この例では nextIdentity という小さなヘルパー関数を使用します。
let nextIdentity = let id = ref 0 fun () -> id := !id + 1 !id
// テストnextIdentity() // 1nextIdentity() // 2nextIdentity() // 3ディレクトリを挿入するには、まずディレクトリ内のすべてのファイルの ID を知る必要があります。
つまり、insertDbFile 関数は生成された ID を返すようにする必要があります。
/// DbFileレコードを挿入し、新しいファイルIDを返すlet insertDbFile name (fileSize:int64) = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s size:%i" "DbFile" id name fileSize id同じことがディレクトリにも当てはまります。
/// DbDirレコードを挿入し、新しいディレクトリIDを返すlet insertDbDir name = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s" "DbDir" id name idしかし、まだ不十分です。子 ID を親ディレクトリに渡す際、ファイルとディレクトリを区別する必要があります。 関係は異なるテーブルに保存されるからです。
問題ありません。選択型を使って、両者を区別しましょう。
type PrimaryKey = | FileId of int | DirId of intこれで、データベース関数の実装を完成させられます。
/// DbFileレコードを挿入し、新しいPrimaryKeyを返すlet insertDbFile name (fileSize:int64) = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s size:%i" "DbFile" id name fileSize FileId id
/// DbDirレコードを挿入し、新しいPrimaryKeyを返すlet insertDbDir name = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s" "DbDir" id name DirId id
/// DbDir_Fileレコードを挿入let insertDbDir_File dirId fileId = printfn "%10s: inserting parentDir:%i childFile:%i" "DbDir_File" dirId fileId
/// DbDir_Dirレコードを挿入let insertDbDir_Dir parentDirId childDirId = printfn "%10s: inserting parentDir:%i childDir:%i" "DbDir_Dir" parentDirId childDirIdカタモーフィズムによる処理
Section titled “カタモーフィズムによる処理”前述のとおり、各ステップで内部 ID が必要なので、fold ではなく cata を使う必要があります。
File ケースを処理する関数は簡単です。挿入して、PrimaryKey を返します。
let fFile (fi:FileInfo) = insertDbFile fi.Name fi.LengthDirectory ケースを処理する関数は、DirectoryInfo と、すでに挿入された子の PrimaryKey のシーケンスを受け取ります。
この関数は、まずメインのディレクトリレコードを挿入し、次に子要素を挿入して、上位レベルの PrimaryKey を返します。
let fDir (di:DirectoryInfo) childIds = let dirId = insertDbDir di.Name // 子を挿入 // 親にIDを返す dirIdディレクトリレコードを挿入して ID を取得した後、子 ID ごとに、childId の種類に応じて
DbDir_File テーブルまたは DbDir_Dir テーブルに挿入します。
let fDir (di:DirectoryInfo) childIds = let dirId = insertDbDir di.Name let parentPK = pkToInt dirId childIds |> Seq.iter (fun childId -> match childId with | FileId fileId -> insertDbDir_File parentPK fileId | DirId childDirId -> insertDbDir_Dir parentPK childDirId ) // 親にIDを返す dirIdまた、PrimaryKey 型から整数 ID を抽出する小さなヘルパー関数 pkToInt も作成しました。
すべてのコードをまとめて以下に示します。
open Systemopen System.IO
let nextIdentity = let id = ref 0 fun () -> id := !id + 1 !id
type PrimaryKey = | FileId of int | DirId of int
/// DbFileレコードを挿入し、新しいPrimaryKeyを返すlet insertDbFile name (fileSize:int64) = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s size:%i" "DbFile" id name fileSize FileId id
/// DbDirレコードを挿入し、新しいPrimaryKeyを返すlet insertDbDir name = let id = nextIdentity() printfn "%10s: inserting id:%i name:%s" "DbDir" id name DirId id
/// DbDir_Fileレコードを挿入let insertDbDir_File dirId fileId = printfn "%10s: inserting parentDir:%i childFile:%i" "DbDir_File" dirId fileId
/// DbDir_Dirレコードを挿入let insertDbDir_Dir parentDirId childDirId = printfn "%10s: inserting parentDir:%i childDir:%i" "DbDir_Dir" parentDirId childDirId
let pkToInt primaryKey = match primaryKey with | FileId fileId -> fileId | DirId dirId -> dirId
let insertFileSystemTree fileSystemItem =
let fFile (fi:FileInfo) = insertDbFile fi.Name fi.Length
let fDir (di:DirectoryInfo) childIds = let dirId = insertDbDir di.Name let parentPK = pkToInt dirId childIds |> Seq.iter (fun childId -> match childId with | FileId fileId -> insertDbDir_File parentPK fileId | DirId childDirId -> insertDbDir_Dir parentPK childDirId ) // 親にIDを返す dirId
fileSystemItem |> Tree.cata fFile fDirそれでは、テストしてみましょう。
// カレントディレクトリをTreeとして取得let currentDir = fromDir (DirectoryInfo("."))
// データベースに挿入currentDir|> insertFileSystemTree出力は次のようなものになるはずです。
DbDir: inserting id:41 name:FoldAndRecursiveTypes DbFile: inserting id:42 name:Fold.fsx size:8315DbDir_File: inserting parentDir:41 childFile:42 DbFile: inserting id:43 name:FoldAndRecursiveTypes.fsproj size:3680DbDir_File: inserting parentDir:41 childFile:43 DbFile: inserting id:44 name:FoldAndRecursiveTypes.sln size:1010DbDir_File: inserting parentDir:41 childFile:44... DbDir: inserting id:57 name:bin DbDir: inserting id:58 name:Debug DbDir_Dir: inserting parentDir:57 childDir:58 DbDir_Dir: inserting parentDir:41 childDir:57ファイルが反復処理されるにつれて ID が生成され、各 DbFile の挿入後に DbDir_File の挿入が続くことがわかります。
この例のソースコードは このgist で入手できます。
例:Tree から JSON へシリアライズ
Section titled “例:Tree から JSON へシリアライズ”別のよくある課題として、木構造をJSON、XML、またはその他の形式にシリアライズおよびデシリアライズすることが挙げられます。
ここでもGiftドメインを使いますが、今回は、Gift型を木構造としてモデル化してみます。つまり、一つの箱に複数のものを入れられるようになります。
Giftドメインを木構造としてモデル化する
Section titled “Giftドメインを木構造としてモデル化する”主要な型はこれまでと変わりませんが、最後のGift型が木構造として定義されている点に注目してください。
type Book = {title: string; price: decimal}type ChocolateType = Dark | Milk | SeventyPercenttype Chocolate = {chocType: ChocolateType ; price: decimal}
type WrappingPaperStyle = | HappyBirthday | HappyHolidays | SolidColor
// 非再帰的なケースのための統一データtype GiftContents = | Book of Book | Chocolate of Chocolate
// 再帰的なケースのための統一データtype GiftDecoration = | Wrapped of WrappingPaperStyle | Boxed | WithACard of string
type Gift = Tree<GiftContents,GiftDecoration>いつものように、Giftの構築を補助するヘルパー関数を作成できます。
let fromBook book = LeafNode (Book book)
let fromChoc choc = LeafNode (Chocolate choc)
let wrapInPaper paperStyle innerGift = let container = Wrapped paperStyle InternalNode (container, [innerGift])
let putInBox innerGift = let container = Boxed InternalNode (container, [innerGift])
let withCard message innerGift = let container = WithACard message InternalNode (container, [innerGift])
let putTwoThingsInBox innerGift innerGift2 = let container = Boxed InternalNode (container, [innerGift;innerGift2])そして、サンプルデータを生成することができます。
let wolfHall = {title="Wolf Hall"; price=20m}let yummyChoc = {chocType=SeventyPercent; price=5m}
let birthdayPresent = wolfHall |> fromBook |> wrapInPaper HappyBirthday |> withCard "Happy Birthday"
let christmasPresent = yummyChoc |> fromChoc |> putInBox |> wrapInPaper HappyHolidays
let twoBirthdayPresents = let thing1 = wolfHall |> fromBook let thing2 = yummyChoc |> fromChoc putTwoThingsInBox thing1 thing2 |> wrapInPaper HappyBirthday
let twoWrappedPresentsInBox = let thing1 = wolfHall |> fromBook |> wrapInPaper HappyHolidays let thing2 = yummyChoc |> fromChoc |> wrapInPaper HappyBirthday putTwoThingsInBox thing1 thing2description のような関数は、内部テキストの リスト を処理する必要があります。そこで、文字列を & で連結します。
let description gift =
let fLeaf leafData = match leafData with | Book book -> sprintf "'%s'" book.title | Chocolate choc -> sprintf "%A chocolate" choc.chocType
let fNode nodeData innerTexts = let innerText = String.concat " & " innerTexts match nodeData with | Wrapped style -> sprintf "%s wrapped in %A paper" innerText style | Boxed -> sprintf "%s in a box" innerText | WithACard message -> sprintf "%s with a card saying '%s'" innerText message
// メイン呼び出し Tree.cata fLeaf fNode gift最後に、この関数が以前と同様に動作し、複数のアイテムを正しく処理できることを確認します。
birthdayPresent |> description// "'Wolf Hall' wrapped in HappyBirthday paper with a card saying 'Happy Birthday'"
christmasPresent |> description// "SeventyPercent chocolate in a box wrapped in HappyHolidays paper"
twoBirthdayPresents |> description// "'Wolf Hall' & SeventyPercent chocolate in a box// wrapped in HappyBirthday paper"
twoWrappedPresentsInBox |> description// "'Wolf Hall' wrapped in HappyHolidays paper// & SeventyPercent chocolate wrapped in HappyBirthday paper// in a box"ステップ1: GiftDto の定義
Section titled “ステップ1: GiftDto の定義”Gift 型は、さまざまな判別共用体で構成されています。経験上、このような型はシリアライゼーションにあまり向いていません。複雑な型は大抵そうなのです。
そこで、シリアライゼーションに最適化された DTO型を定義するのが一般的です。 具体的には、以下の制約を守って DTO 型を設計します。
- レコード型のみ使用する
- レコードのフィールドは、
int、string、boolなどのプリミティブな値のみ使用する
これにより、次のような利点も得られます。
シリアライゼーションの出力を制御できます。 このようなデータ型は、ほとんどのシリアライザーで同じように扱われます。 一方、 判別共用体のような特殊な型は、ライブラリによって解釈が異なる場合があります。
エラー処理をより良くコントロールできます。 シリアライズされたデータを取り扱う際の鉄則は、「信用しない」です。 データ自体は正しい構造を持っていても、ドメイン的におかしなことがよくあります。 たとえば、本来 null ではありえない文字列が null だったり、文字列の長さがオーバーしたり、整数値が範囲外だったりします。
DTO を使うことで、デシリアライゼーション処理自体は確実に機能するようになります。その後、DTO をドメイン型に変換する際に、 適切なバリデーションを行うことができます。
では、ドメイン用の DTO 型を定義してみましょう。各 DTO 型はドメイン型に対応するので、まずは GiftContents から始めます。
対応する DTO 型として、GiftContentsDto を以下のように定義します。
[<CLIMutableAttribute>]type GiftContentsDto = { discriminator : string // "Book" または "Chocolate" // "Book"ケースのみ bookTitle: string // "Chocolate"ケースのみ chocolateType : string // "Dark" "Milk" "SeventyPercent"のいずれか // すべてのケース price: decimal }ご覧の通り、元の GiftContents とは大きく異なります。違いを見てみましょう。
- まず、
CLIMutableAttributeが付与されています。これにより、デシリアライザーはリフレクションを使ってオブジェクトを構築できるようになります。 - 次に、
discriminator(判別子) があり、元の判別共用体のどのケースが使用されているかを判別します。 この文字列はどんな値でも設定できるので、DTO からドメイン型に戻す際には慎重にチェックする必要があります。 - その次は、保存が必要なデータ項目ごとに 1 つずつフィールドが用意されています。たとえば、
BookのケースではbookTitleが必要ですが、Chocolateのケースではチョコレートの種類が必要です。 最後に、どちらのケースにも存在するpriceフィールドがあります。 なお、チョコレートの種類も文字列として保持されるので、DTO からドメインに変換する際に特別な扱いが必要になります。
GiftDecorationDto 型も同様に、判別子と文字列を使って作成されます。判別共用体は使われません。
[<CLIMutableAttribute>]type GiftDecorationDto = { discriminator: string // "Wrapped" または "Boxed" または "WithACard" // "Wrapped"ケースのみ wrappingPaperStyle: string // "HappyBirthday" または "HappyHolidays" または "SolidColor" // "WithACard"ケースのみ message: string }最後に、2 つの DTO 型で構成された木構造を持つ GiftDto 型を定義します。
type GiftDto = Tree<GiftContentsDto,GiftDecorationDto>ステップ 2: Gift から GiftDto への変換
Section titled “ステップ 2: Gift から GiftDto への変換”DTO 型を定義したので、
次に、Tree.map 関数を使って Gift を GiftDto へ変換します。
変換を行うには、GiftContents から GiftContentsDto へ、GiftDecoration から GiftContentsDto へ変換する関数をそれぞれ用意する必要があります。
以下は giftToDto 関数のコードです。コード自体はわかりやすいので、詳細な説明は省略します。
let giftToDto (gift:Gift) :GiftDto =
let fLeaf leafData :GiftContentsDto = match leafData with | Book book -> {discriminator= "Book"; bookTitle=book.title; chocolateType=null; price=book.price} | Chocolate choc -> let chocolateType = sprintf "%A" choc.chocType {discriminator= "Chocolate"; bookTitle=null; chocolateType=chocolateType; price=choc.price}
let fNode nodeData :GiftDecorationDto = match nodeData with | Wrapped style -> let wrappingPaperStyle = sprintf "%A" style {discriminator= "Wrapped"; wrappingPaperStyle=wrappingPaperStyle; message=null} | Boxed -> {discriminator= "Boxed"; wrappingPaperStyle=null; message=null} | WithACard message -> {discriminator= "WithACard"; wrappingPaperStyle=null; message=message}
// メイン呼び出し Tree.map fLeaf fNode giftコードを見ると、Book や Chocolate などのケースは discriminator 文字列に変換され、chocolateType も同様に文字列に変換されていることがわかります。
これは、上で説明した通りです。
ステップ 3: TreeDto の定義
Section titled “ステップ 3: TreeDto の定義”適切な DTO はレコード型であるべきだと説明しました。木のノードは変換しましたが、木自体はまだ共用体型です。
したがって、Tree 型も TreeDto 型のようなものに変換する必要があります。
変換方法は、ギフトの DTO 型と同様に、すべてのデータを含むレコード型を作成します。
前と同じように discriminator フィールドを使用することもできますが、今回はリーフノードと内部ノードの 2 種類しかないため、デシリアライズ時に値が null かどうかをチェックするだけで十分です。
リーフ値が null でない場合は、レコードが LeafNode ケースを表し、そうでない場合は InternalNode ケースを表します。
データ型の定義は以下の通りです。
/// 木構造を表すDTO/// Leaf/Nodeの選択はレコードに変換される[<CLIMutableAttribute>]type TreeDto<'LeafData,'NodeData> = { leafData : 'LeafData nodeData : 'NodeData subtrees : TreeDto<'LeafData,'NodeData>[] }以前と同じように、この型には CLIMutableAttribute が適用されています。また、すべての選択肢のデータを格納するためのフィールドも備えています。
subtrees は、シリアライザーが扱いやすいように、シーケンスではなく配列として格納されています。
TreeDto を作成するには、お馴染みの cata 関数を使って通常の Tree からレコードを組み立てます。
/// TreeをTreeDtoに変換するlet treeToDto tree : TreeDto<'LeafData,'NodeData> =
let fLeaf leafData = let nodeData = Unchecked.defaultof<'NodeData> let subtrees = [||] {leafData=leafData; nodeData=nodeData; subtrees=subtrees}
let fNode nodeData subtrees = let leafData = Unchecked.defaultof<'NodeData> let subtrees = subtrees |> Seq.toArray {leafData=leafData; nodeData=nodeData; subtrees=subtrees}
// 再帰的にTreeDtoを構築 Tree.cata fLeaf fNode treeF# ではレコードは null を許容しないため、欠けているデータを示すには null ではなく Unchecked.defaultof<'NodeData'> を使っています。
また、LeafData や NodeData は参照型であることを前提としています。
もし LeafData や NodeData が int や bool といった値型である場合、このアプローチは機能しなくなります。なぜなら、既定値と欠けている値を区別できなくなるからです。
そのような場合は、前のように discriminator フィールドを使ってください。
あるいは、IDictionary を使うこともできます。この場合、デシリアライズは少し面倒になりますが、null チェックの必要性はなくなります。
ステップ 4: TreeDto のシリアライズ
Section titled “ステップ 4: TreeDto のシリアライズ”最後に、JSON シリアライザーを使って TreeDto をシリアライズできます。
この例では、NuGet パッケージに依存しなくて済むように、組み込みの DataContractJsonSerializer を使っています。
本格的なプロジェクトでは、より適したシリアライザーを使用することもできます。
#r "System.Runtime.Serialization.dll"
open System.Runtime.Serializationopen System.Runtime.Serialization.Json
let toJson (o:'a) = let serializer = new DataContractJsonSerializer(typeof<'a>) let encoding = System.Text.UTF8Encoding() use stream = new System.IO.MemoryStream() serializer.WriteObject(stream,o) stream.Close() encoding.GetString(stream.ToArray())ステップ 5: パイプラインの組み立て
Section titled “ステップ 5: パイプラインの組み立て”ここまでの手順をまとめると、次のようなパイプラインになります。
giftToDto関数を使ってGiftをGiftDtoに変換します。
つまり、Tree<GiftContents, GiftDecoration>からTree<GiftContentsDto, GiftDecorationDto>へ変換するためにTree.map関数を使います。treeToDto関数を使ってTreeをTreeDtoに変換します。
つまり、Tree<GiftContentsDto, GiftDecorationDto>からTreeDto<GiftContentsDto, GiftDecorationDto>へ変換するためにTree.cata関数を使います。TreeDtoを JSON 文字列にシリアライズします。
コード例は次のとおりです。
let goodJson = christmasPresent |> giftToDto |> treeToDto |> toJson生成される JSON 出力は次のようになります。
{ "leafData@": null, "nodeData@": { "discriminator@": "Wrapped", "message@": null, "wrappingPaperStyle@": "HappyHolidays" }, "subtrees@": [ { "leafData@": null, "nodeData@": { "discriminator@": "Boxed", "message@": null, "wrappingPaperStyle@": null }, "subtrees@": [ { "leafData@": { "bookTitle@": null, "chocolateType@": "SeventyPercent", "discriminator@": "Chocolate", "price@": 5 }, "nodeData@": null, "subtrees@": [] } ] } ]}フィールド名の前にある見栄えの悪い @ 記号は、F# のレコード型をシリアライズする際の副作用です。
少しの手間で修正できますが、今回は割愛します。
この例のソースコードは このgist で入手できます。
例:JSON から Tree へデシリアライズ
Section titled “例:JSON から Tree へデシリアライズ”JSON を作成したので、今度は逆に JSON を読み込んで Gift に変換してみましょう。
簡単です。パイプラインを逆にするだけです。
- JSON 文字列を
TreeDtoにデシリアライズします。 dtoToTree関数を使ってTreeDtoをTreeに変換します。
つまり、TreeDto<GiftContentsDto, GiftDecorationDto>からTree<GiftContentsDto, GiftDecorationDto>へ変換します。 ここではcataは使えず、小さな再帰ループを作成する必要があります。dtoToGift関数を使ってGiftDtoをGiftに変換します。
つまり、Tree<GiftContentsDto, GiftDecorationDto>からTree<GiftContents, GiftDecoration>へ変換するためにTree.map関数を使います。
ステップ 1: TreeDto のデシリアライズ
Section titled “ステップ 1: TreeDto のデシリアライズ”JSON シリアライザーを使って TreeDto をデシリアライズできます。
let fromJson<'a> str = let serializer = new DataContractJsonSerializer(typeof<'a>) let encoding = System.Text.UTF8Encoding() use stream = new System.IO.MemoryStream(encoding.GetBytes(s=str)) let obj = serializer.ReadObject(stream) obj :?> 'aデシリアライズに失敗した場合どうなるでしょうか。今回はエラー処理を無視して、例外を伝播させます。
ステップ 2: TreeDto から Tree への変換
Section titled “ステップ 2: TreeDto から Tree への変換”TreeDto を Tree に変換するには、レコードとその部分木を再帰的にループ処理し、
適切なフィールドが null かどうかによってそれぞれを InternalNode または LeafNode に変換します。
let rec dtoToTree (treeDto:TreeDto<'Leaf,'Node>) :Tree<'Leaf,'Node> = let nullLeaf = Unchecked.defaultof<'Leaf> let nullNode = Unchecked.defaultof<'Node>
// nodeDataが存在するかチェック if treeDto.nodeData <> nullNode then if treeDto.subtrees = null then failwith "ノードデータが存在する場合、subtreesはnullであってはいけません" else let subtrees = treeDto.subtrees |> Array.map dtoToTree InternalNode (treeDto.nodeData,subtrees) // leafDataが存在するかチェック elif treeDto.leafData <> nullLeaf then LeafNode (treeDto.leafData) // 両方が欠けている場合は失敗 else failwith "リーフまたはノードデータが必要です"ご覧のように、いくつかの問題が発生する可能性があります。
leafDataフィールドとnodeDataフィールドがどちらも null だった場合nodeDataフィールドが null ではなく、subtreesフィールドが null だった場合
ここでも、エラー処理は無視して例外をスローするだけにします (今のところ)。
質問: TreeDto 用の cata を作成して、このコードを簡潔にできますか?作成する価値はありますか?
ステップ 3: GiftDto から Gift への変換
Section titled “ステップ 3: GiftDto から Gift への変換”適切な木構造が得られたら、Tree.map 関数を使って、各リーフノードと内部ノードを DTO 型から実際のドメイン型に変換します。
そのためには、GiftContentsDto を GiftContents に、GiftDecorationDto を GiftDecoration に変換する関数が必要です。
コード全体は以下の通りです。逆方向の変換よりもかなり複雑になっています。
コードは次のようにグループ化されています。
- 文字列を適切なドメイン型に変換し、入力が不正な場合は例外をスローするヘルパー関数(たとえば、
strToChocolateType) - DTO 全体を変換するケース変換関数(たとえば、
bookFromDto) - 最後に、
dtoToGift関数自体です。この関数はdiscriminatorフィールドを見て、呼び出すべきケース変換関数を選択し、discriminatorの値が認識されない場合は例外をスローします。
let strToBookTitle str = match str with | null -> failwith "BookTitleはnullであってはいけません" | _ -> str
let strToChocolateType str = match str with | "Dark" -> Dark | "Milk" -> Milk | "SeventyPercent" -> SeventyPercent | _ -> failwithf "ChocolateType %s は認識されません" str
let strToWrappingPaperStyle str = match str with | "HappyBirthday" -> HappyBirthday | "HappyHolidays" -> HappyHolidays | "SolidColor" -> SolidColor | _ -> failwithf "WrappingPaperStyle %s は認識されません" str
let strToCardMessage str = match str with | null -> failwith "CardMessageはnullであってはいけません" | _ -> str
let bookFromDto (dto:GiftContentsDto) = let bookTitle = strToBookTitle dto.bookTitle Book {title=bookTitle; price=dto.price}
let chocolateFromDto (dto:GiftContentsDto) = let chocType = strToChocolateType dto.chocolateType Chocolate {chocType=chocType; price=dto.price}
let wrappedFromDto (dto:GiftDecorationDto) = let wrappingPaperStyle = strToWrappingPaperStyle dto.wrappingPaperStyle Wrapped wrappingPaperStyle
let boxedFromDto (dto:GiftDecorationDto) = Boxed
let withACardFromDto (dto:GiftDecorationDto) = let message = strToCardMessage dto.message WithACard message
/// GiftDtoをGiftに変換するlet dtoToGift (giftDto:GiftDto) :Gift=
let fLeaf (leafDto:GiftContentsDto) = match leafDto.discriminator with | "Book" -> bookFromDto leafDto | "Chocolate" -> chocolateFromDto leafDto | _ -> failwithf "不明なリーフディスクリミネータ '%s'" leafDto.discriminator
let fNode (nodeDto:GiftDecorationDto) = match nodeDto.discriminator with | "Wrapped" -> wrappedFromDto nodeDto | "Boxed" -> boxedFromDto nodeDto | "WithACard" -> withACardFromDto nodeDto | _ -> failwithf "不明なノードディスクリミネータ '%s'" nodeDto.discriminator
// Treeを写像する Tree.map fLeaf fNode giftDtoステップ 4: パイプラインの組み立て
Section titled “ステップ 4: パイプラインの組み立て”これで、JSON 文字列を受け取って Gift オブジェクトを作成するパイプラインを組み立てることができます。
let goodGift = goodJson |> fromJson |> dtoToTree |> dtoToGift
// 説明が変わっていないか確認goodGift |> description// "SeventyPercent chocolate in a box wrapped in HappyHolidays paper"この方法でも動作しますが、エラー処理がひどいものです。
JSON を少し壊してみましょう。
let badJson1 = goodJson.Replace("leafData","leafDataXX")
let badJson1_result = badJson1 |> fromJson |> dtoToTree |> dtoToGift// 例外 "データ契約型'TreeDto'は必要なデータメンバー'leafData@'が見つからなかったためデシリアライズできません。"すると、見栄えの悪い例外が発生します。
または、判別子が間違っていたらどうでしょうか?
let badJson2 = goodJson.Replace("Wrapped","Wrapped2")
let badJson2_result = badJson2 |> fromJson |> dtoToTree |> dtoToGift// 例外 "不明なノードディスクリミネータ 'Wrapped2'"あるいは、WrappingPaperStyleの値が間違っていたら?
let badJson3 = goodJson.Replace("HappyHolidays","HappyHolidays2")let badJson3_result = badJson3 |> fromJson |> dtoToTree |> dtoToGift// 例外 "WrappingPaperStyle HappyHolidays2 は認識されません"多くの例外が発生しますが、関数型プログラミングでは、可能な限り例外を排除するように努めるべきです。
その方法については、次のセクションで説明します。
この例のソースコードは このgist で入手できます。
例:JSON から Tree へデシリアライズ - エラー処理版
Section titled “例:JSON から Tree へデシリアライズ - エラー処理版”エラー処理の問題に対処するために、以下のような Result 型を使用します。
type Result<'a> = | Success of 'a | Failure of string listここでは、この型がどのように機能するかは説明しません。 このアプローチに慣れていない場合は、私の記事 または関数型エラー処理に関する私の講演資料 を参照してください。
前のセクションのすべてのステップをもう一度見直して、例外をスローする代わりに Result 型を使ってみましょう。
ステップ 1: TreeDto のデシリアライズ
Section titled “ステップ 1: TreeDto のデシリアライズ”JSON シリアライザーを使って TreeDto をデシリアライズする際、例外を捕捉して Result に変換します。
let fromJson<'a> str = try let serializer = new DataContractJsonSerializer(typeof<'a>) let encoding = System.Text.UTF8Encoding() use stream = new System.IO.MemoryStream(encoding.GetBytes(s=str)) let obj = serializer.ReadObject(stream) obj :?> 'a |> Result.retn with | ex -> Result.failWithMsg ex.Messageこれで、fromJson 関数のシグネチャは string -> Result<'a> になりました。
ステップ2: TreeDto から Tree へ
Section titled “ステップ2: TreeDto から Tree へ”前回の変換処理と同様に、レコードとその部分木を再帰的にループ処理して、 TreeDto を Tree に変換します。各要素は InternalNode または LeafNode に変換します。
今回は、エラー処理のために Result 型を使用します。
let rec dtoToTreeOfResults (treeDto:TreeDto<'Leaf,'Node>) :Tree<Result<'Leaf>,Result<'Node>> = let nullLeaf = Unchecked.defaultof<'Leaf> let nullNode = Unchecked.defaultof<'Node>
// nodeDataが存在するかチェック if treeDto.nodeData <> nullNode then if treeDto.subtrees = null then LeafNode <| Result.failWithMsg "ノードデータが存在する場合、subtreesはnullであってはいけません" else let subtrees = treeDto.subtrees |> Array.map dtoToTreeOfResults InternalNode (Result.retn treeDto.nodeData,subtrees) // leafDataが存在するかチェック elif treeDto.leafData <> nullLeaf then LeafNode <| Result.retn (treeDto.leafData) // 両方が欠けている場合は失敗 else LeafNode <| Result.failWithMsg "リーフまたはノードデータが必要です"
// val dtoToTreeOfResults :// treeDto:TreeDto<'Leaf,'Node> -> Tree<Result<'Leaf>,Result<'Node>>しかし、これではすべての内部ノードとリーフノードが Result でラップされてしまい、結果的に Result の木構造になってしまいます。
型としては Tree<Result<'Leaf>, Result<'Node>> になり、見栄えが悪いです。
このままでは使えません。本来欲しいのは、すべてのエラーをまとめて Tree を含む Result を返すことです。
では、「 Result の木構造」 を 「木構造 の Result 」へ変換するにはどうすればよいでしょうか?
答えは sequence 関数を使うことです。
sequence 関数は、二つの型を「入れ替える」ような働きをします。sequence については、持ち上げられた世界に関するシリーズ で詳しく説明されています。
注: 少し複雑な traverse 関数を使えば map と sequence を一度のステップで結合することもできますが、
今回の例ではステップを分けることで理解しやすくしています。
Tree と Result の組み合わせのための sequence 関数を作成する必要があります。
幸い、sequence 関数の作成は機械的なプロセスで行えます。
- 下位の型(
Result)にはapplyとreturn関数を定義する必要があります。applyの意味はこちらを参照してください。 - 上位の型(
Tree)にはcata関数が必要です。これは既にあります。 - カタモーフィズムでは、上位型の各コンストラクタ(
LeafNodeとInternalNode)をResult型に「持ち上げる」(例:retn LeafNode <*> data)ように置き換えます。
これが実際のコードです。すぐには理解できなくても心配しないでください。一度この関数を定義すれば、
以降の Tree と Result の組み合わせでも同じように使えます。
/// ResultのTreeをTreeのResultに変換するlet sequenceTreeOfResult tree = // 下位レベルから let (<*>) = Result.apply let retn = Result.retn
// 走査可能なレベルから let fLeaf data = retn LeafNode <*> data
let fNode data subitems = let makeNode data items = InternalNode(data,items) let subItems = Result.sequenceSeq subitems retn makeNode <*> data <*> subItems
// 走査を行う Tree.cata fLeaf fNode tree
// val sequenceTreeOfResult :// tree:Tree<Result<'a>,Result<'b>> -> Result<Tree<'a,'b>>最後に、実際の dtoToTree 関数はとても簡単です。treeDto を dtoToTreeOfResults に渡し、sequenceTreeOfResult を使って最終結果を Result<Tree<..>> に変換するだけです。
これがまさに我々が求めていたものです。
let dtoToTree treeDto = treeDto |> dtoToTreeOfResults |> sequenceTreeOfResult
// val dtoToTree : treeDto:TreeDto<'a,'b> -> Result<Tree<'a,'b>>ステップ3: GiftDto から Gift へ
Section titled “ステップ3: GiftDto から Gift へ”こちらも Tree.map を使って、リーフノードと内部ノードをそれぞれ DTO から適切なドメイン型に変換します。
ただし、今回の関数はエラー処理を行うため、GiftContentsDto を Result<GiftContents> に、GiftDecorationDto を Result<GiftDecoration> に変換する必要があります。
結果として、またしても「 Result の木構造」になってしまうため、
sequenceTreeOfResult を再び使って正しい Result<Tree<..>> の形に戻す必要があります。
まずは、文字列を適切なドメイン型に変換するヘルパーメソッド (strToChocolateType など) を作成します。
今回は例外をスローするのではなく、Result を返します。
let strToBookTitle str = match str with | null -> Result.failWithMsg "BookTitleはnullであってはいけません" | _ -> Result.retn str
let strToChocolateType str = match str with | "Dark" -> Result.retn Dark | "Milk" -> Result.retn Milk | "SeventyPercent" -> Result.retn SeventyPercent | _ -> Result.failWithMsg (sprintf "ChocolateType %s は認識されません" str)
let strToWrappingPaperStyle str = match str with | "HappyBirthday" -> Result.retn HappyBirthday | "HappyHolidays" -> Result.retn HappyHolidays | "SolidColor" -> Result.retn SolidColor | _ -> Result.failWithMsg (sprintf "WrappingPaperStyle %s は認識されません" str)
let strToCardMessage str = match str with | null -> Result.failWithMsg "CardMessageはnullであってはいけません" | _ -> Result.retn strケース変換メソッドは、通常の値ではなく Result である引数から、Book や Chocolate を構築する必要があります。
このような場合に、Result.lift2 のような「持ち上げ」関数が役立ちます。
持ち上げの仕組みについては、持ち上げに関する記事とアプリカティブを使った検証に関する記事 を参照してください。
let bookFromDto (dto:GiftContentsDto) = let book bookTitle price = Book {title=bookTitle; price=price}
let bookTitle = strToBookTitle dto.bookTitle let price = Result.retn dto.price Result.lift2 book bookTitle price
let chocolateFromDto (dto:GiftContentsDto) = let choc chocType price = Chocolate {chocType=chocType; price=price}
let chocType = strToChocolateType dto.chocolateType let price = Result.retn dto.price Result.lift2 choc chocType price
let wrappedFromDto (dto:GiftDecorationDto) = let wrappingPaperStyle = strToWrappingPaperStyle dto.wrappingPaperStyle Result.map Wrapped wrappingPaperStyle
let boxedFromDto (dto:GiftDecorationDto) = Result.retn Boxed
let withACardFromDto (dto:GiftDecorationDto) = let message = strToCardMessage dto.message Result.map WithACard messageそして最後に、dtoToGift 関数自体が、discriminator が不正な場合に Result を返すように変更されています。
変換処理によりやはり Result の木構造が生成されるため、Tree.map の出力を sequenceTreeOfResult に渡して …
`Tree.map fLeaf fNode giftDto |> sequenceTreeOfResult`… 木構造の Result を返します。
dtoToGift の完全なコードは次のとおりです。
open TreeDto_WithErrorHandling
/// GiftDtoをResult<Gift>に変換するlet dtoToGift (giftDto:GiftDto) :Result<Gift>=
let fLeaf (leafDto:GiftContentsDto) = match leafDto.discriminator with | "Book" -> bookFromDto leafDto | "Chocolate" -> chocolateFromDto leafDto | _ -> Result.failWithMsg (sprintf "不明なリーフディスクリミネータ '%s'" leafDto.discriminator)
let fNode (nodeDto:GiftDecorationDto) = match nodeDto.discriminator with | "Wrapped" -> wrappedFromDto nodeDto | "Boxed" -> boxedFromDto nodeDto | "WithACard" -> withACardFromDto nodeDto | _ -> Result.failWithMsg (sprintf "不明なノードディスクリミネータ '%s'" nodeDto.discriminator)
// Treeを写像する Tree.map fLeaf fNode giftDto |> sequenceTreeOfResultdtoToGift の型シグネチャが変更されました。以前は単に Gift を返していましたが、今回からは Result<Gift> を返すようになりました。
// val dtoToGift : GiftDto -> Result<GiftUsingTree.Gift>ステップ4: パイプラインの組み立て
Section titled “ステップ4: パイプラインの組み立て”JSON 文字列を受け取って Gift オブジェクトを作成するパイプラインを、再度組み立てましょう。
ただし、新しいエラー処理コードを使用するために、以下の変更が必要です。
fromJson関数はResult<TreeDto>を返しますが、パイプラインの次の関数 (dtoToTree) は通常のTreeDtoを入力として想定しています。- 同様に、
dtoToTreeはResult<Tree>を返しますが、次の関数 (dtoToGift) は通常のTreeを入力として想定しています。
どちらの場合も、Result.bind を使って、この出力/入力の不一致の問題を解決できます。bindの詳細な説明はこちらを参照してください。
それでは、以前作成した goodJson 文字列のデシリアライズを試してみましょう。
let goodGift = goodJson |> fromJson |> Result.bind dtoToTree |> Result.bind dtoToGift
// 説明が変わっていないか確認goodGift |> description// Success "SeventyPercent chocolate in a box wrapped in HappyHolidays paper"問題ありません。
エラー処理が改善されたかどうかを確認しましょう。 もう一度 JSON を不正な形式にしてみます。
let badJson1 = goodJson.Replace("leafData","leafDataXX")
let badJson1_result = badJson1 |> fromJson |> Result.bind dtoToTree |> Result.bind dtoToGift// Failure ["'TreeDto'型のデータ契約を必要なデータメンバー'leafData@'が見つからなかったためデシリアライズできません。"]素晴らしい! きちんと Failure ケースが得られました。
では、判別子が間違っていたらどうでしょうか?
let badJson2 = goodJson.Replace("Wrapped","Wrapped2")let badJson2_result = badJson2 |> fromJson |> Result.bind dtoToTree |> Result.bind dtoToGift// Failure ["不明なノードディスクリミネータ 'Wrapped2'"]あるいは、 WrappingPaperStyle の値のいずれかが間違っていたら?
let badJson3 = goodJson.Replace("HappyHolidays","HappyHolidays2")let badJson3_result = badJson3 |> fromJson |> Result.bind dtoToTree |> Result.bind dtoToGift// Failure ["WrappingPaperStyle HappyHolidays2 は認識されません"]ここでも、Failure ケースが正しく動作しています。
非常に重要な点として(例外処理アプローチでは提供できませんが)、複数のエラーが存在する場合、 さまざまなエラーを集約して、一度に 1 つのエラーではなく、すべての問題点をリスト化することができます。
この動作を確認しましょう。2 つのエラーを JSON 文字列に導入してみます。
// 2つのエラーを作成let badJson4 = goodJson.Replace("HappyHolidays","HappyHolidays2") .Replace("SeventyPercent","SeventyPercent2")let badJson4_result = badJson4 |> fromJson |> Result.bind dtoToTree |> Result.bind dtoToGift// Failure ["WrappingPaperStyle HappyHolidays2 は認識されません";// "ChocolateType SeventyPercent2 は認識されません"]以上のように、今回の取り組みは成功だったと言えるでしょう。
この例のソースコードは このgist で入手できます。
このシリーズでは、カタモーフィズムと畳み込みの定義方法、そして特に今回の記事においては、現実的な問題解決に使う方法を解説しました。 このシリーズが皆様にとって有用なものであり、ご自身のコードに適用できるヒントや洞察を提供できたことを願っています。
シリーズは当初の予定よりも長くなってしまいましたが、最後までお読みいただきありがとうございました! ではまた!