ビルダーの実装:オーバーロード
この記事では寄り道して、コンピュテーション式ビルダーのメソッドでできるいくつかの技を見ていきます。
最終的に、この回り道は行き止まりに終わるでしょう。しかし、この道のりの中で、独自のコンピュテーション式を設計するためのより良い方法について、新たな気づきが得られることを期待しています。
洞察:ビルダーメソッドはオーバーロードできる
Section titled “洞察:ビルダーメソッドはオーバーロードできる”ある時点で、こんな洞察を得るかもしれません。
- ビルダーメソッドは普通のクラスメソッドです。スタンドアロンの関数とは違い、メソッドは異なるパラメータ型でのオーバーロードをサポートします。つまり、パラメータの型が異なる限り、任意のメソッドに対して異なる実装を作れるのです。
そして、これをどう活用できるだろうかと興奮するかもしれません。しかし、実際には思ったほど役立たないことがわかります。いくつか例を見てみましょう。
“return”のオーバーロード
Section titled ““return”のオーバーロード”ユニオン型があるとします。各ユニオンケースに対して複数の実装でReturn
やYield
をオーバーロードすることを考えるかもしれません。
たとえば、Return
に2つのオーバーロードがある非常に簡単な例を示します。
type SuccessOrError =| Success of int| Error of string
type SuccessOrErrorBuilder() =
member this.Bind(m, f) = match m with | Success s -> f s | Error _ -> m
/// intを受け入れるようにオーバーロード member this.Return(x:int) = printfn "Return a success %i" x Success x
/// stringを受け入れるようにオーバーロード member this.Return(x:string) = printfn "Return an error %s" x Error x
// ワークフローのインスタンスを作成let successOrError = new SuccessOrErrorBuilder()
これを使用すると次のようになります。
successOrError { return 42 } |> printfn "Result for success: %A"// Result for success: Success 42
successOrError { return "error for step 1" } |> printfn "Result for error: %A"//Result for error: Error "error for step 1"
これに何の問題があるのでしょうか?
まず、ラッパー型に関する議論に戻ると、ラッパー型はジェネリックであるべきだと指摘しました。ワークフローは可能な限り再利用可能であるべきです。なぜ実装を特定のプリミティブ型に縛る必要があるのでしょうか?
この場合、ユニオン型を次のように再設計する必要があります。
type SuccessOrError<'a,'b> =| Success of 'a| Error of 'b
しかし、ジェネリックの結果として、Return
メソッドはもはやオーバーロードできなくなります!
第二に、内部の型を式の中で露出させるのは良くない考えかもしれません。「成功」と「失敗」のケースという概念は有用ですが、より良い方法は「失敗」ケースを隠し、Bind
の中で自動的に処理することです。次のようにします。
type SuccessOrError<'a,'b> =| Success of 'a| Error of 'b
type SuccessOrErrorBuilder() =
member this.Bind(m, f) = match m with | Success s -> try f s with | e -> Error e.Message | Error _ -> m
member this.Return(x) = Success x
// ワークフローのインスタンスを作成let successOrError = new SuccessOrErrorBuilder()
このアプローチでは、Return
は成功の場合にのみ使用され、失敗のケースは隠されます。
successOrError { return 42 } |> printfn "Result for success: %A"
successOrError { let! x = Success 1 return x/0 } |> printfn "Result for error: %A"
この技法についてはこの後の記事でさらに詳しく見ていきます。
複数のCombine実装
Section titled “複数のCombine実装”メソッドをオーバーロードしたくなるもう一つのケースは、Combine
を実装するときです。
trace
ワークフローのCombine
メソッドを再考してみましょう。以前のCombine
の実装では、単に数字を足し合わせていました。
しかし、要件を変更して次のようにしたらどうでしょうか。
trace
ワークフローで複数の値をyieldする場合、それらをリストに結合したい。
combine
を使用した最初の試みは次のようになるでしょう。
member this.Combine (a,b) = match a,b with | Some a', Some b' -> printfn "combining %A and %A" a' b' Some [a';b'] | Some a', None -> printfn "combining %A with None" a' Some [a'] | None, Some b' -> printfn "combining None with %A" b' Some [b'] | None, None -> printfn "combining None with None" None
Combine
メソッドでは、渡されたオプションから値を取り出し、それらをリストに結合してSome
でラップします(例:Some [a';b']
)。
2つのyieldの場合、期待通りに動作します。
trace { yield 1 yield 2 } |> printfn "Result for yield then yield: %A"
// Result for yield then yield: Some [1; 2]
None
をyieldする場合も、期待通りに動作します。
trace { yield 1 yield! None } |> printfn "Result for yield then None: %A"
// Result for yield then None: Some [1]
しかし、3つの値を結合する場合はどうなるでしょうか?次のような場合です。
trace { yield 1 yield 2 yield 3 } |> printfn "Result for yield x 3: %A"
これを試すと、コンパイラエラーが発生します。
error FS0001: Type mismatch. Expecting a int optionbut given a 'a list optionThe type 'int' does not match the type ''a list'
問題は何でしょうか?
答えは、2番目と3番目の値(yield 2; yield 3
)を結合した後、整数のリストを含むオプション、つまりint list option
が得られることです。エラーは1番目の値(Some 1
)と結合された値(Some [2;3]
)を結合しようとしたときに発生します。つまり、Combine
の2番目のパラメータにint list option
を渡していますが、1番目のパラメータは通常のint option
のままです。コンパイラは2番目のパラメータが1番目と同じ型であることを要求しています。
ここで、オーバーロードの技を使いたくなるかもしれません。2番目のパラメータの型が異なるCombine
の2つの異なる実装を作成できます。1つはint option
を受け取り、もう1つはint list option
を受け取ります。
以下は、異なるパラメータ型を持つ2つのメソッドです。
/// リストオプションと結合member this.Combine (a, listOption) = match a,listOption with | Some a', Some list -> printfn "combining %A and %A" a' list Some ([a'] @ list) | Some a', None -> printfn "combining %A with None" a' Some [a'] | None, Some list -> printfn "combining None with %A" list Some list | None, None -> printfn "combining None with None" None
/// リストでないオプションと結合member this.Combine (a,b) = match a,b with | Some a', Some b' -> printfn "combining %A and %A" a' b' Some [a';b'] | Some a', None -> printfn "combining %A with None" a' Some [a'] | None, Some b' -> printfn "combining None with %A" b' Some [b'] | None, None -> printfn "combining None with None" None
これで、前述の3つの結果を結合すると、期待通りの結果が得られます。
trace { yield 1 yield 2 yield 3 } |> printfn "Result for yield x 3: %A"
// Result for yield x 3: Some [1; 2; 3]
残念ながら、この技は以前のコードを壊してしまいました!今None
をyieldしようとすると、コンパイラエラーが発生します。
trace { yield 1 yield! None } |> printfn "Result for yield then None: %A"
エラーは次のようになります。
error FS0041: A unique overload for method 'Combine' could not be determined based on type information prior to this program point. A type annotation may be needed.
しかし、イライラする前に、コンパイラの立場で考えてみてください。あなたがコンパイラで、None
が与えられたら、どちらのメソッドを呼び出しますか?
正解はありません。なぜなら、None
はどちらのメソッドの2番目のパラメータとしても渡せるからです。コンパイラは、これがint list option
型のNone
(1番目のメソッド)なのか、int option
型のNone
(2番目のメソッド)なのかわかりません。
コンパイラが言う通り、型注釈が役立ちます。None
をint option
型に強制してみましょう。
trace { yield 1 let x:int option = None yield! x } |> printfn "Result for yield then None: %A"
これは確かに醜いですが、実際にはあまり頻繁には起こらないかもしれません。
より重要なのは、これは設計のまずさを示す手がかりだということです。このコンピュテーション式は、時に'a option
を返し、時に'a list option
を返します。設計では一貫性を保つべきですから、yield
の数に関わらず、コンピュテーション式が常に同じ型を返すようにする必要があります。
つまり、複数のyield
を許可したい場合は、最初から単なるオプションではなく'a list option
をラッパー型として使うべきです。この場合、Yield
メソッドがリストオプションを作成し、Combine
メソッドは再び単一のメソッドに統合できます。
以下は3番目のバージョンのコードです。
type TraceBuilder() = member this.Bind(m, f) = match m with | None -> printfn "Binding with None. Exiting." | Some a -> printfn "Binding with Some(%A). Continuing" a Option.bind f m
member this.Zero() = printfn "Zero" None
member this.Yield(x) = printfn "Yield an unwrapped %A as a list option" x Some [x]
member this.YieldFrom(m) = printfn "Yield an option (%A) directly" m m
member this.Combine (a, b) = match a,b with | Some a', Some b' -> printfn "combining %A and %A" a' b' Some (a' @ b') | Some a', None -> printfn "combining %A with None" a' Some a' | None, Some b' -> printfn "combining None with %A" b' Some b' | None, None -> printfn "combining None with None" None
member this.Delay(f) = printfn "Delay" f()
// ワークフローのインスタンスを作成let trace = new TraceBuilder()
これで、サンプルのコードは特別な工夫なしに期待通りに動作します。
trace { yield 1 yield 2 } |> printfn "Result for yield then yield: %A"
// Result for yield then yield: Some [1; 2]
trace { yield 1 yield 2 yield 3 } |> printfn "Result for yield x 3: %A"
// Result for yield x 3: Some [1; 2; 3]
trace { yield 1 yield! None } |> printfn "Result for yield then None: %A"
// Result for yield then None: Some [1]
コードがよりクリーンになっただけでなく、Return
の例と同様に、特定の型(int option
)からよりジェネリックな型('a option
)へと、コードをよりジェネリックにしました。
“For”のオーバーロード
Section titled ““For”のオーバーロード”オーバーロードが必要になる正当なケースの1つはFor
メソッドです。考えられる理由としては:
- さまざまな種類のコレクション(例:リストと
IEnumerable
)をサポートしたい場合 - 特定の種類のコレクションに対して、より効率的なループ実装がある場合
- リストの「ラップされた」バージョン(例:LazyList)があり、ラップされていない値とラップされた値の両方でループをサポートしたい場合
以下は、シーケンスもサポートするように拡張されたリストビルダーの例です。
type ListBuilder() = member this.Bind(m, f) = m |> List.collect f
member this.Yield(x) = printfn "Yield an unwrapped %A as a list" x [x]
member this.For(m,f) = printfn "For %A" m this.Bind(m,f)
member this.For(m:_ seq,f) = printfn "For %A using seq" m let m2 = List.ofSeq m this.Bind(m2,f)
// ワークフローのインスタンスを作成let listbuilder = new ListBuilder()
使用例は以下のとおりです。
listbuilder { let list = [1..10] for i in list do yield i } |> printfn "Result for list: %A"
listbuilder { let s = seq {1..10} for i in s do yield i } |> printfn "Result for seq : %A"
2つ目のFor
メソッドをコメントアウトすると、「シーケンス」の例がコンパイルに失敗することがわかります。つまり、オーバーロードが必要なのです。
メソッドは必要に応じてオーバーロードできますが、考えなしにこの解決策に飛びつかないよう注意が必要です。オーバーロードが必要になることは、設計が良くないサインかもしれません。
次の記事では、式が評価されるタイミングを正確に制御する話に戻ります。今度はビルダーの外部で遅延を使用します。