前回の投稿では、「ラッパー型」の概念とコンピュテーション式との関係について見てきました。今回の投稿では、どのような型がラッパー型として適しているかを調査します。
どのような種類の型がラッパー型になり得るか?
すべてのコンピュテーション式には関連するラッパー型が必要だとすれば、どのような種類の型がラッパー型として使えるのでしょうか?特別な制約や制限が適用されるのでしょうか?
一般的な規則が1つあります。
- ジェネリックパラメータを持つ任意の型をラッパー型として使える
つまり、これまで見てきたように、Option<T>
、DbResult<T>
などをラッパー型として使えます。また、Vector<int>
のような型パラメータを制限したラッパー型も使えます。
では、List<T>
やIEnumerable<T>
のような他のジェネリック型はどうでしょうか?使えないのではないでしょうか?実は、使えるのです!後ほどその方法を見ていきます。
非ジェネリックなラッパー型は機能するか?
ジェネリックパラメータを持たないラッパー型を使うことは可能でしょうか?
たとえば、以前の例で文字列の加算を試みました。"1" + "2"
のようなものです。
この場合、string
をint
のラッパー型として扱うことはできないでしょうか?それができれば素晴らしいですよね?
試してみましょう。Bind
とReturn
のシグネチャを指針に実装できます。
Bind
はタプルを受け取ります。タプルの最初の部分はラップされた型(この場合はstring
)で、2番目の部分はアンラップされた型を受け取り、ラップされた型に変換する関数です。この場合、それはint -> string
になります。Return
はアンラップされた型(この場合はint
)を受け取り、ラップされた型に変換します。つまり、この場合のReturn
のシグネチャはint -> string
になります。
これらはどのように実装の指針となるでしょうか?
- 「再ラップ」関数
int -> string
の実装は簡単です。intの「toString」そのものです。 - bind関数は文字列をintにアンラップし、それを関数に渡す必要があります。これには
int.Parse
を使えます。 - しかし、bind関数が文字列をアンラップできない場合(有効な数字でない場合)はどうなるでしょうか?この場合、bind関数は依然としてラップされた型(文字列)を返さなければならないので、"error"のような文字列を返すことができます。
以下がビルダークラスの実装です。
type StringIntBuilder() =
member this.Bind(m, f) =
let b,i = System.Int32.TryParse(m)
match b,i with
| false,_ -> "error"
| true,i -> f i
member this.Return(x) =
sprintf "%i" x
let stringint = new StringIntBuilder()
これを使ってみましょう。
let good =
stringint {
let! i = "42"
let! j = "43"
return i+j
}
printfn "good=%s" good
文字列の1つが無効な場合はどうなるでしょうか?
let bad =
stringint {
let! i = "42"
let! j = "xxx"
return i+j
}
printfn "bad=%s" bad
これは素晴らしく見えます。ワークフロー内で文字列をintとして扱えています!
しかし、問題があります。
ワークフローに入力を与え、それをアンラップ(let!
で)し、他に何もせずにすぐに再ラップ(return
で)したらどうなるでしょうか?
let g1 = "99"
let g2 = stringint {
let! i = g1
return i
}
printfn "g1=%s g2=%s" g1 g2
問題ありません。入力g1
と出力g2
は、期待通り同じ値です。
しかし、エラーの場合はどうでしょうか?
let b1 = "xxx"
let b2 = stringint {
let! i = b1
return i
}
printfn "b1=%s b2=%s" b1 b2
この場合、予期せぬ動作が起きています。入力b1
と出力b2
が同じ値ではありません。矛盾が生じています。
実際にこれが問題になるでしょうか?分かりません。しかし、この方法は避け、すべての場合で一貫性のあるオプションのようなアプローチを使うべきでしょう。
ラッパー型を使うワークフローのルール
質問です。次の2つのコード断片の違いは何で、異なる動作をすべきでしょうか?
// リファクタリング前の断片
myworkflow {
let wrapped = // 何らかのラップされた値
let! unwrapped = wrapped
return unwrapped
}
// リファクタリング後の断片
myworkflow {
let wrapped = // 何らかのラップされた値
return! wrapped
}
答えは「いいえ」で、異なる動作をすべきではありません。唯一の違いは、2つ目の例ではunwrapped
値がリファクタリングで消え、wrapped
値が直接返されていることです。
しかし、前のセクションで見たように、注意しないと矛盾が生じる可能性があります。そのため、作成する実装は以下の標準的なルールに従うべきです。
ルール1:アンラップされた値から始めて、それをラップ(return
を使用)し、その後アンラップ(bind
を使用)した場合、常に元のアンラップされた値が返されるべきです。
このルールと次のルールは、値をラップしたりアンラップしたりする際に情報を失わないことに関するものです。これは当然のことで、リファクタリングが期待通りに機能するために必要です。
コードで表すと、以下のようになります。
myworkflow {
let originalUnwrapped = something
// ラップする
let wrapped = myworkflow { return originalUnwrapped }
// アンラップする
let! newUnwrapped = wrapped
// 同じであることを確認
assertEqual newUnwrapped originalUnwrapped
}
ルール2:ラップされた値から始めて、それをアンラップ(bind
を使用)し、その後ラップ(return
を使用)した場合、常に元のラップされた値が返されるべきです。
これは、上記のstringInt
ワークフローが破ったルールです。ルール1と同様、これも明らかに必要条件です。
コードで表すと、以下のようになります。
myworkflow {
let originalWrapped = something
let newWrapped = myworkflow {
// アンラップする
let! unwrapped = originalWrapped
// ラップする
return unwrapped
}
// 同じであることを確認
assertEqual newWrapped originalWrapped
}
ルール3:子ワークフローを作成する場合、メインワークフローでロジックを「インライン化」した場合と同じ結果を生成する必要があります。
このルールは、合成が適切に動作するために必要で、「抽出」リファクタリングもこれが正しい場合にのみ正しく機能します。
一般的に、いくつかのガイドライン(後の投稿で説明します)に従えば、このルールは自動的に満たされます。
コードで表すと、以下のようになります。
// インライン化
let result1 = myworkflow {
let! x = originalWrapped
let! y = f x // xに対する何らかの関数
return! g y // yに対する何らかの関数
}
// 子ワークフローを使用(「抽出」リファクタリング)
let result2 = myworkflow {
let! y = myworkflow {
let! x = originalWrapped
return! f x // xに対する何らかの関数
}
return! g y // yに対する何らかの関数
}
// ルール
assertEqual result1 result2
リストをラッパー型として使う
先ほど、List<T>
やIEnumerable<T>
のような型もラッパー型として使えると述べました。しかし、どうしてそれが可能なのでしょうか?ラッパー型とアンラップされた型の間に1対1の対応関係がないのに?
ここで「ラッパー型」のアナロジーは少し誤解を招くかもしれません。代わりに、bind
を一つの式の出力を別の式の入力に接続する方法として考え直しましょう。
これまで見てきたように、bind
関数は型を「アンラップ」し、アンラップされた値に継続関数を適用します。しかし、定義上、アンラップされた値が1つだけでなければならないという制約はありません。リストの各項目に対して継続関数を適用することも可能です。
言い換えれば、リストと継続関数を受け取るbind
を書くことができるはずです。この継続関数は一度に1つの要素を処理します。
bind( [1;2;3], fun elem -> // 単一の要素を使用する式 )
この概念を使えば、以下のように複数のbind
をチェーンでつなげることができるはずです。
let add =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
elem1 + elem2
))
しかし、重要なことを見逃しています。bind
に渡される継続関数には特定のシグネチャが必要です。アンラップされた型を受け取りますが、ラップされた型を生成する必要があります。
つまり、継続関数は常に結果として新しいリストを作成しなければなりません。
bind( [1;2;3], fun elem -> // 単一の要素を使用する式、リストを返す )
そしてチェーンの例は、elem1 + elem2
の結果をリストに変換して、次のように書く必要があります。
let add =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
[elem1 + elem2] // リスト!
))
したがって、bind メソッドのロジックは次のようになります。
let bind(list,f) =
// 1) リストの各要素にfを適用
// 2) fはリストを返す(シグネチャの要求通り)
// 3) 結果はリストのリスト
ここでもう一つ問題があります。Bind
自体もラップされた型を生成しなければならないため、「リストのリスト」では不適切です。これらを単純な「1レベル」のリストに戻す必要があります。
しかし、これは簡単です。リストモジュールにはちょうどそれを行う関数があり、concat
と呼ばれています。
これらをまとめると、以下のようになります。
let bind(list,f) =
list
|> List.map f
|> List.concat
let added =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
// elem1 + elem2 // エラー
[elem1 + elem2] // 正しくリストを返す
))
bind
が単独でどのように機能するかを理解したので、「リストワークフロー」を作成できます。
Bind
は渡されたリストの各要素に継続関数を適用し、結果のリストのリストを1レベルのリストに平坦化します。List.collect
はまさにそれを行うライブラリ関数です。Return
はアンラップされたものをラップされたものに変換します。この場合、単に単一の要素をリストにラップすることを意味します。
type ListWorkflowBuilder() =
member this.Bind(list, f) =
list |> List.collect f
member this.Return(x) =
[x]
let listWorkflow = new ListWorkflowBuilder()
以下はこのワークフローの使用例です。
let added =
listWorkflow {
let! i = [1;2;3]
let! j = [10;11;12]
return i+j
}
printfn "added=%A" added
let multiplied =
listWorkflow {
let! i = [1;2;3]
let! j = [10;11;12]
return i*j
}
printfn "multiplied=%A" multiplied
結果を見ると、最初のコレクションの各要素が2番目のコレクションの各要素と組み合わされていることがわかります。
val added : int list = [11; 12; 13; 12; 13; 14; 13; 14; 15]
val multiplied : int list = [10; 11; 12; 20; 22; 24; 30; 33; 36]
これは本当に驚くべきことです。リストの列挙ロジックを完全に隠蔽し、ワークフロー自体だけを残しています。
"for"のシンタックスシュガー
リストやシーケンスを特別なケースとして扱うことで、let!
をより自然なものに置き換える素敵なシンタックスシュガーを追加できます。
let!
をfor..in..do
式に置き換えることができます。
// letバージョン
let! i = [1;2;3] in [何らかの式]
// for..in..doバージョン
for i in [1;2;3] do [何らかの式]
どちらも意味はまったく同じですが、見た目が異なります。
F#コンパイラがこれを可能にするには、ビルダークラスにFor
メソッドを追加する必要があります。通常、通常のBind
メソッドとまったく同じ実装ですが、シーケンス型を受け入れる必要があります。
type ListWorkflowBuilder() =
member this.Bind(list, f) =
list |> List.collect f
member this.Return(x) =
[x]
member this.For(list, f) =
this.Bind(list, f)
let listWorkflow = new ListWorkflowBuilder()
以下はその使用例です。
let multiplied =
listWorkflow {
for i in [1;2;3] do
for j in [10;11;12] do
return i*j
}
printfn "multiplied=%A" multiplied
LINQと「リストワークフロー」
for element in collection do
は見覚えがありませんか?LINQで使用されるfrom element in collection ...
構文と非常に似ています。
実際、LINQは基本的に同じ技術を使用して、from element in collection ...
のようなクエリ式構文を実際のメソッド呼び出しに変換しています。
F#では、先ほど見たように、bind
はList.collect
関数を使います。LINQにおけるList.collect
の相当物はSelectMany
拡張メソッドです。
SelectMany
の動作を理解すれば、同様の種類のクエリを自分で実装できます。Jon Skeetが役立つブログ投稿でこれについて説明しています。
アイデンティティ「ラッパー型」
この投稿ではいくつかのラッパー型を見てきました。そして、すべてのコンピュテーション式には関連するラッパー型が必要だと述べました。
しかし、前回の投稿のロギング例はどうでしょうか?そこにはラッパー型がありませんでした。裏で何かを行うlet!
はありましたが、入力型と出力型は同じでした。型は変更されませんでした。
この短い答えは、任意の型を自身の「ラッパー」として扱えるということです。しかし、これを理解するためのより深い方法があります。
一歩下がって、List<T>
のようなラッパー型の定義が実際に何を意味するのか考えてみましょう。
List<T>
のような型は、実際には「実際の」型ではありません。List<int>
は実際の型で、List<string>
も実際の型です。しかし、List<T>
だけでは不完全です。実際の型になるために必要なパラメータが欠けています。
List<T>
を考える一つの方法は、型ではなく関数だということです。通常の値の具体的な世界ではなく、型の抽象的な世界における関数ですが、他の関数と同様に値を他の値にマップします。ただし、この場合、入力値は型(int
やstring
など)で、出力値は他の型(List<int>
やList<string>
)です。そして、他の関数と同様にパラメータを取りますが、この場合は「型パラメータ」です。これが、.NET開発者が「ジェネリクス」と呼ぶ概念が、コンピューターサイエンスの用語では「パラメトリック多相」として知られる理由です。
ある型から別の型を生成する関数(「型コンストラクタ」と呼ばれる)の概念を理解すれば、「ラッパー型」が実際に意味するのは単なる型コンストラクタだということがわかります。
しかし、「ラッパー型」が単にある型を別の型にマップする関数だとすれば、型を同じ型にマップする関数もこのカテゴリーに当てはまるのではないでしょうか?実際、そうです。型に対する「アイデンティティ」関数は定義に合致し、コンピュテーション式のラッパー型として使用できます。
実際のコードに戻ると、「アイデンティティワークフロー」をワークフロービルダーの最もシンプルな実装として定義できます。
type IdentityBuilder() =
member this.Bind(m, f) = f m
member this.Return(x) = x
member this.ReturnFrom(x) = x
let identity = new IdentityBuilder()
let result = identity {
let! x = 1
let! y = 2
return x + y
}
これを踏まえると、先ほど議論したロギング例は、単にアイデンティティワークフローにロギングを追加しただけのものだとわかります。
まとめ
また長い投稿になりましたが、多くのトピックを扱いました。ラッパー型の役割がより明確になったことを願っています。このシリーズの後半で「ライターワークフロー」や「ステートワークフロー」などの一般的なワークフローを見ていく際に、ラッパー型がどのように実践で使用されるかを見ていきます。
この投稿で扱ったポイントをまとめると以下のようになります。
- コンピュテーション式の主要な用途の1つは、何らかのラッパー型に格納された値をアンラップしたり再ラップしたりすることです。
- コンピュテーション式は簡単に合成できます。
Return
の出力をBind
の入力に渡せるからです。 - すべてのコンピュテーション式には関連するラッパー型が必要です。
- ジェネリックパラメータを持つ任意の型は、リストでさえもラッパー型として使用できます。
- ワークフローを作成する際は、ラップとアンラップ、そして合成に関する3つの合理的なルールに従うように実装を確認すべきです。