この記事では寄り道して、コンピュテーション式ビルダーのメソッドでできるいくつかの技を見ていきます。

最終的に、この回り道は行き止まりに終わるでしょう。しかし、この道のりの中で、独自のコンピュテーション式を設計するためのより良い方法について、新たな気づきが得られることを期待しています。

洞察:ビルダーメソッドはオーバーロードできる

ある時点で、こんな洞察を得るかもしれません。

  • ビルダーメソッドは普通のクラスメソッドです。スタンドアロンの関数とは違い、メソッドは異なるパラメータ型でのオーバーロードをサポートします。つまり、パラメータの型が異なる限り、任意のメソッドに対して異なる実装を作れるのです。

そして、これをどう活用できるだろうかと興奮するかもしれません。しかし、実際には思ったほど役立たないことがわかります。いくつか例を見てみましょう。

"return"のオーバーロード

ユニオン型があるとします。各ユニオンケースに対して複数の実装でReturnYieldをオーバーロードすることを考えるかもしれません。

たとえば、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実装

メソッドをオーバーロードしたくなるもう一つのケースは、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 option    
but given a
    'a list option    
The 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番目のパラメータの型が異なるCombine2つの異なる実装を作成できます。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番目のメソッド)なのかわかりません。

コンパイラが言う通り、型注釈が役立ちます。Noneint 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"のオーバーロード

オーバーロードが必要になる正当なケースの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メソッドをコメントアウトすると、「シーケンス」の例がコンパイルに失敗することがわかります。つまり、オーバーロードが必要なのです。

まとめ

メソッドは必要に応じてオーバーロードできますが、考えなしにこの解決策に飛びつかないよう注意が必要です。オーバーロードが必要になることは、設計が良くないサインかもしれません。

次の記事では、式が評価されるタイミングを正確に制御する話に戻ります。今度はビルダーの外部で遅延を使用します。

results matching ""

    No results matching ""