Skip to content

ビルダーの実装:遅延性の追加

以前の記事で、ワークフロー内の式を必要になるまで評価しないようにする方法を見ました。

しかし、その方法はワークフロー内部の式を対象としていました。では、ワークフロー全体を必要になるまで遅延させたい場合はどうすればよいでしょうか。

以下は「maybe」ビルダークラスのコードです。このコードは以前の記事のtraceビルダーを基にしていますが、トレース処理をすべて取り除いて、シンプルにしています。

type MaybeBuilder() =
member this.Bind(m, f) =
Option.bind f m
member this.Return(x) =
Some x
member this.ReturnFrom(x) =
x
member this.Zero() =
None
member this.Combine (a,b) =
match a with
| Some _ -> a // aが正常なら、bをスキップ
| None -> b() // aが不正なら、bを実行
member this.Delay(f) =
f
member this.Run(f) =
f()
// ワークフローのインスタンスを作成
let maybe = new MaybeBuilder()

先に進む前に、これがどのように動作するか理解しておいてください。以前の記事の用語を使って分析すると、使われている型は次のようになります。

  • ラッパー型:'a option
  • 内部型:'a option
  • 遅延型:unit -> 'a option

では、このコードをチェックして、すべてが期待通りに動作するか確認しましょう。

maybe {
printfn "パート1:1を返す直前"
return 1
printfn "パート2:returnの後"
} |> printfn "パート1の結果(パート2は実行されない):%A"
// 結果 - 2番目の部分は評価されない
maybe {
printfn "パート1:Noneを返す直前"
return! None
printfn "パート2:Noneの後、続行"
} |> printfn "パート1とパート2の結果:%A"
// 結果 - 2番目の部分は評価される

しかし、コードを子ワークフローにリファクタリングした場合はどうなるでしょうか。

let childWorkflow =
maybe {printfn "子ワークフロー"}
maybe {
printfn "パート1:1を返す直前"
return 1
return! childWorkflow
} |> printfn "パート1の結果(子ワークフローは実行されない):%A"

出力を見ると、子ワークフローは結局必要なかったにもかかわらず評価されています。この場合は問題ないかもしれませんが、多くの場合、これを避けたいでしょう。

では、どうすれば避けられるでしょうか。

明らかな方法は、ビルダーの結果全体を遅延関数でラップし、結果を「実行」するには単に遅延関数を評価するだけにすることです。

そこで、新しいラッパー型を次のように定義します。

type Maybe<'a> = Maybe of (unit -> 'a option)

単純なoptionをオプションを評価する関数に置き換え、その関数を単一ケースユニオンでラップしました。

そして、Runメソッドも変更する必要があります。以前は渡された遅延関数を評価していましたが、今は評価せずに新しいラッパー型でラップするだけにします。

// 変更前
member this.Run(f) =
f()
// 変更後
member this.Run(f) =
Maybe f

他のメソッドも1つ修正し忘れています。どのメソッドか分かりますか?すぐに気づくでしょう!

もう1つ、結果を「実行」する方法が必要になります。

let run (Maybe f) = f()

前の例で新しい型を試してみましょう。

let m1 = maybe {
printfn "パート1:1を返す直前"
return 1
printfn "パート2:returnの後"
}

これを実行すると、次のような結果が得られます。

val m1 : Maybe<int> = Maybe <fun:m1@123-7>

良さそうです。他には何も出力されていません。

では、実行してみましょう。

run m1 |> printfn "パート1の結果(パート2は実行されない):%A"

出力は次のようになります。

パート1:1を返す直前
パート1の結果(パート2は実行されない):Some 1

完璧です。パート2は実行されませんでした。

しかし、次の例で問題にぶつかります。

let m2 = maybe {
printfn "パート1:Noneを返す直前"
return! None
printfn "パート2:Noneの後、続行"
}

おっと!ReturnFromの修正を忘れていました!ご存知の通り、このメソッドはラップされた型を受け取りますが、今やラップされた型を再定義しています。

修正は次のとおりです。

member this.ReturnFrom(Maybe f) =
f()

外部からMaybeを受け取り、すぐに実行してオプションを取得します。

しかし、今度は別の問題が発生します。return! Noneで明示的にNoneを返すことができなくなりました。代わりにMaybe型を返す必要があります。どうやってこれを作ればいいのでしょうか?

ヘルパー関数を作成してMaybe型を構築することもできますが、もっと簡単な方法があります。 maybe式を使って新しいMaybe型を作れるのです!

let m2 = maybe {
return! maybe {printfn "パート1:Noneを返す直前"}
printfn "パート2:Noneの後、続行"
}

これがZeroメソッドが役立つ理由です。Zeroとビルダーインスタンスがあれば、何もしない新しい型のインスタンスでも作成できます。

しかし、ここでもう一つのエラーが発生します。恐ろしい「値の制限」です。

Value restriction. The value 'm2' has been inferred to have generic type

これが起こる理由は、両方の式がNoneを返しているからです。しかし、コンパイラはNoneがどの型なのか分かりません。コードはOption<obj>型のNoneを使っています(おそらく暗黙的なボックス化のため)が、コンパイラはその型がもっとジェネリックになり得ることを知っています。

2つの解決策があります。1つは型を明示的にすることです。

let m2_int: Maybe<int> = maybe {
return! maybe {printfn "パート1:Noneを返す直前"}
printfn "パート2:Noneの後、続行"
}

もう1つは、単にNone以外の値を返すことです。

let m2 = maybe {
return! maybe {printfn "パート1:Noneを返す直前"}
printfn "パート2:Noneの後、続行"
return 1
}

これらの解決策のどちらでも問題は解決します。

例を実行すると、結果は期待通りになります。今回は2番目の部分実行されます。

run m2 |> printfn "パート1とパート2の結果:%A"

トレース出力:

パート1:Noneを返す直前
パート2:Noneの後、続行
パート1とパート2の結果:Some 1

最後に、子ワークフローの例をもう一度試してみましょう。

let childWorkflow =
maybe {printfn "子ワークフロー"}
let m3 = maybe {
printfn "パート1:1を返す直前"
return 1
return! childWorkflow
}
run m3 |> printfn "パート1の結果(子ワークフローは実行されない):%A"

これで、望んでいた通り子ワークフローは評価されません。

そして、子ワークフローを評価する必要がある場合も、次のように動作します。

let m4 = maybe {
return! maybe {printfn "パート1:Noneを返す直前"}
return! childWorkflow
}
run m4 |> printfn "パート1と子ワークフローの結果:%A"

新しいビルダークラスのコード全体をもう一度見てみましょう。

type Maybe<'a> = Maybe of (unit -> 'a option)
type MaybeBuilder() =
member this.Bind(m, f) =
Option.bind f m
member this.Return(x) =
Some x
member this.ReturnFrom(Maybe f) =
f()
member this.Zero() =
None
member this.Combine (a,b) =
match a with
| Some _' -> a // aが正常なら、bをスキップ
| None -> b() // aが不正なら、bを実行
member this.Delay(f) =
f
member this.Run(f) =
Maybe f
// ワークフローのインスタンスを作成
let maybe = new MaybeBuilder()
let run (Maybe f) = f()

以前の記事の用語を使ってこの新しいビルダーを分析すると、使用されている型は次のようになります。

  • ラッパー型:Maybe<'a>
  • 内部型:'a option
  • 遅延型:unit -> 'a option

この場合、標準の'a optionを内部型として使うのが便利でした。BindReturnを全く変更する必要がなかったからです。

別の設計として、内部型にもMaybe<'a>を使うこともできます。これによりより一貫性が出ますが、コードの読みにくさが増します。

最後の例の変形を見てみましょう。

let child_twice: Maybe<unit> = maybe {
let workflow = maybe {printfn "子ワークフロー"}
return! maybe {printfn "パート1:Noneを返す直前"}
return! workflow
return! workflow
}
run child_twice |> printfn "子ワークフローを2回実行した結果:%A"

何が起こるでしょうか?子ワークフローは何回実行されるべきでしょうか?

上記の遅延実装では、子ワークフローが要求時にのみ評価されることは保証されますが、2回実行されるのを止めることはできません。

状況によっては、ワークフローが最大1回だけ実行され、その後キャッシュされる(「メモ化」される)ことを保証する必要があるかもしれません。これはF#に組み込まれているLazy型を使えば簡単に実現できます。

必要な変更点は次の通りです。

  • Maybeを変更して、遅延の代わりにLazyをラップする
  • ReturnFromrunを変更して、遅延値の評価を強制する
  • Runを変更して、lazy内から遅延を実行する

変更を加えた新しいクラスは次のようになります。

type Maybe<'a> = Maybe of Lazy<'a option>
type MaybeBuilder() =
member this.Bind(m, f) =
Option.bind f m
member this.Return(x) =
Some x
member this.ReturnFrom(Maybe f) =
f.Force()
member this.Zero() =
None
member this.Combine (a,b) =
match a with
| Some _' -> a // aが正常なら、bをスキップ
| None -> b() // aが不正なら、bを実行
member this.Delay(f) =
f
member this.Run(f) =
Maybe (lazy f())
// ワークフローのインスタンスを作成
let maybe = new MaybeBuilder()
let run (Maybe f) = f.Force()

そして、上記の「子を2回実行する」コードを動かすと、次のような結果になります。

パート1:Noneを返す直前
子ワークフロー
子ワークフローを2回実行した結果:<null>

これから、子ワークフローが1回だけ実行されたことが明らかです。

まとめ:即時 vs. 遅延 vs. 遅延評価

Section titled “まとめ:即時 vs. 遅延 vs. 遅延評価”

このページでは、maybeワークフローの3つの異なる実装を見てきました。常に即時評価される実装、遅延関数を使う実装、そしてメモ化を伴う遅延評価を使う実装です。

では… どのアプローチを使うべきでしょうか?

唯一の「正解」はありません。選択は以下のような要因に依存します。

  • 式内のコードの実行コストが低く、重要な副作用がないなら? この場合は、最初の即時バージョンを使いましょう。単純で理解しやすく、ほとんどのmaybeワークフローの実装がまさにこの方法を使っています。
  • 式内のコードの実行コストが高い、呼び出しごとに結果が変わる可能性がある(非決定的)、または重要な副作用があるなら? この場合は、2番目の遅延バージョンを使います。これはほとんどの他のワークフロー、特にI/O関連(asyncなど)のワークフローが行っていることです。
  • F#は純粋な関数型言語を目指しているわけではないので、ほとんどすべてのF#コードはこの2つのカテゴリのいずれかに該当します。しかし、保証された副作用のないスタイルでコーディングする必要がある場合、または高コストのコードが最大1回しか評価されないことを保証したい場合は、3番目の遅延評価オプションを使います。

どの選択をしても、ドキュメントで明確にしておくことが重要です。たとえば、遅延と遅延評価の実装はクライアントにとっては全く同じに見えますが、セマンティクスが大きく異なり、クライアントコードはそれぞれのケースで異なる書き方をする必要があります。

これで遅延と遅延評価について終わりましたので、ビルダーメソッドに戻って仕上げていきましょう。